ifelとループ
他の言語では制御構造としてまとめられる事の多いループと条件分岐ですが、 プログラムカウンタによるランダムなジャンプというCPUとはGPUが大きく異なる所でもある為、 MFGが他の言語とは違う側面が目立つ部分になっています。
ここではMFGの条件選択系の機能とループ系の機能を見ていきます。
MFGとしてはあまりセットにする理由も無いifelとループ系の機能ですが、 他の言語との対比からまとめてここで扱います。
ifelによる条件選択
MFGによる条件選択は、単なる関数のようなもので実現されています。
let s = ifel(x > 0, 1, 2)
sにはxが0より大きけば1が、0より小さければ2が入ります。
シンタックスは以下となります。
ifel( COND, TRUE_VALUE, FALSE_VALUE )
CONDが0でなければTRUE_VALUEを、0であればFALSE_VLUEを返します。
厳密な事をいうとMFGの関数はオーバーロードをサポートしていないけれどifelはオーバーロードされているように振る舞うので、 内部的には少しだけ関数と異なっていますが、関数と思って差し支えありません。
他の言語では条件分岐、と呼ばれるものですが、MFGでは分岐はしないので条件選択と呼んでおきます。
許容される引数とベクトライズ
ifelはCOND, TRUE_VALUE, FALSE_VALUEの型に関連があって、許される組み合わせが決まっています。
- TRUE_VALUEとFALSE_VALUEはいつも同じ型でなくてはいけない
- CONDは整数(iかu)のスカラーかタプル
- CONDがタプルの時は、TRUE_VALUEは同じ次元のタプルでなくてはいけない
3が少し細かい話になるので見ていきます。 まずCONDは基本的には整数のスカラーかタプルです。
CONDが例えば[0, 1, 2]の時、これはi32の3要素のタプルですが、 この時はTRUE_VALUEは3要素のタプルでなくてはいけません。 要素数さえあっていれば、中の要素の型は任意で構いませんが、FALSE_VALUEとは一致している必要があります。
ifel([0, 1, 2], [3, 3.0, 3u], [5, 0.0, 1u])
この場合、それぞれの要素についてifelが実行されて最後にタプルになる、 以下の式と同じ意味になります。
[ifel(0, 3, 5),
ifel(1, 3.0, 0.0),
ifel(2, 3u, 1u)]
結果の型はi32, f32, u32のタプルとなります。 値としてはこの場合は[5, 3.0, 3u]になります。
式とベクトライズ演算でやった二項演算のベクトライズと組み合わせると、簡潔に処理を書く事が出来る場合があります。
let flag = [1, 2, 3]
ifel(flag%2 == 0, [3, 3.0, 3u], [5, 0.0, 1u])
flagの要素が偶数のものはTRUE_VALUEの要素を、奇数のものはFALSE_VALUEの要素を選びます。
この、スカラーまたはTRUE_VALUEの次元と同じ整数のタプル、という型制約はかなり特殊で、ifelでしかありません。
ifelの結果の型はいつもTRUE_VALUEと同じとなる事に注目するとコードが読みやすくなるかもしれません。
...のシンタックスシュガーでネストを避ける
ifelは、「条件Aだったらこれをやる、条件Bだったらこれをやる、条件Cだったらこれをやる、条件Dだったらこれをやる…」という処理をやる事が多く、 この場合は呼び出しが深いネストになってしまい、並列構造がわかりにくくなります。
ifel(aCond, aVal,
ifel(bCond, bVal,
ifel(cCond, cVal,
ifel(dCond, dVal, otherVal))))
そこで、関数呼び出しのシンタックスシュガーである「最後の引数を...にすると、カッコの次の式が最後の引数になる」という機能を使う事で、以下のように書けます。
ifel(aCond, aVal, ...)
ifel(bCond, bVal, ...)
ifel(cCond, cVal, ...)
ifel(dCond, dVal, otherVal)
両者は同じ意味ですが、呼び出しのネストがなくなりました。
パース時にネストしたコードに変換して実行するので、両者は全く同じ内部表現になります。
この ... は関数呼び出し全般で使える機能ですが、実質ifelやelifでしか使いません。
elif, elseのシンタックスシュガー
ifel関数は、elifという名前でも呼び出す事が出来ます。 プログラム処理系としてはelifとifelは全く同じで区別しません。 ですが書く側の人間の便宜で、通常最初の条件をifelにし、以後の並列するifelはelifとします。
これは他の言語のelse if的な読み方が出来るように、という事です。 前述の...と合わせて以下のように使います。
ifel(aCond, aVal, ...)
elif(bCond, bVal, ...)
elif(cCond, cVal, ...)
elif(dCond, dVal, otherVal)
以下はこの機能を使っている実際の例として、MLAA(モーフォロジカルアンチエイリアス)のコードから持ってきたものです:
ifel(alreadyEnd,
accm2,
...)
elif(i == 0,
ifel(curEdge,
[0, 0],
[-1, -1]),
...)
elif(prevEnd,
accm2|END_FOUND_MASK,
accm2+1)
普通のプログラム言語のように読む事が出来ると思います(かなり複雑なコードなので読むのは楽ではありませんが)。
また、elseという引数をそのまま返す、何もしない関数というものもあります。 これも、上記と合わせて使う事で、読みやすくする為だけに存在するものです。
上記の例では、最後の otherVal はdCondのFALSE_VALUEとして置かれていますが、 多くのケースでここの値はそれまでの全ての条件にマッチしなかった場合の値、 という意味で使われるため、dCondの中に入っているのは誤解を招く部分があります。
そこでelseを使うと以下のように書けます。
ifel(aCond, aVal, ...)
elif(bCond, bVal, ...)
elif(cCond, cVal, ...)
elif(dCond, dVal, ...)
else(otherVal)
elseもelifも読みやすくするためのものであって機能としてはifelだけで全て書く事が出来ます。
ループ系の機能
MFGについては、ループは大きく3つに分けられます。
- 範囲を元に値を返すもの
- テンソルを元に値を返すもの
- テンソルを元にテンソルを返すもの
ここでは1と2について扱い、3についてはtransとreduceで扱う事にします。
tensor reduceは2と3の両方の場合がありますが、これも「transとreduce」の方で扱います。
まず1に相当するものを見ていきましょう。reduceとrsumです。
MFGのループ系機能の特徴としては以下の特徴があります。
- 値を返す(唯一の例外はts.for_each)
- ループを途中で抜ける事は出来ない(必ず全ループ実行される)
- ループの範囲はループ開始時点で確定していて変更不可
これらのおかげで、コードから不要な副作用が排除出来てコードが安全になり、 コード上の見え方と実際のGPUのハードウェアの動作も自然と一致します。
rsum
rsumはreduce sumの略で、より高機能なreduceを使って同じ事が出来ます。
以下はrsumを使った例です。
let s = rsum(0..<4) |i| { i*2 }
この結果は、0*2+1*2+2*2+3*2となり、つまり12となります。
rsumのシンタックス
rsumは後述するように1次元と2次元をサポートするので、それぞれを記すと以下のようなシンタックスになります。
# 1次元のrsum
rsum(範囲) |i| {...}
# 2次元のrsum
rsum(範囲1, 範囲2) |i, j| {...}
範囲とブロック引数はループ系の関数全般で共通ですが、 rsumが初めて登場するループ系関数なので、ここで詳しく見ておきましょう。
範囲
範囲は 0..<3 などのように、 ..< で指定します。 現状は関数呼び出しの引数でしか使う事は出来ません。
シンタックスは以下です。
開始のインデックス..<終わりのインデックス
範囲は半開区間となっていて、終わりのインデックスは含みません。 例えば 0..<3 は0から始まって2まで、つまり、0, 1, 2の範囲を表します。
開始と終了のインデックスは現時点では整数のみ対応しています。 負の値も使う事が出来、変数なども使えます。
例えば、-2..<3で、-2, -1, 0, 1, 2となります。
開始のインデックスの方が大きい場合はループは一度も実行されませんが、この挙動は将来変更するかもしれないのでそういう場合は無いようにコードを書きましょう。
ブロック引数
rsumの最後などにある、|i| {...}の部分をブロック引数といいます。
ブロックは |仮引数リスト| { ボディ } というシンタックスで、 このボディの部分の最後の部分は式でないといけません。 この最後の式がこのブロックを評価した値(と型)となります。
let a = rsum(0..<5) |i| {
let col = inputEx(x+i, y)
col.x+col.y # この最後の式がこのブロックを評価した結果の値となる
}
最後が式でないといけない、というのは、ようするにletで終わってはいけない、という事です。
仮引数リストが何になるかはブロック引数をとる関数とそこまでの引数によって必ず決定されます。 rsumの場合は範囲引数の個数によってブロック引数の仮引数リストの個数が決まります。
1次元だと一つ、2次元だと2つの仮引数リストとなり、どちらも型はi32です。
let a = rsum(0..<5, 0..<5) |i, j| {
let col = inputEx(x+i, y+j)
col.x+col.y # この最後の式がこのブロックを評価した結果の値となる
}
後述するreduceの場合はinitの型と範囲引数の個数によってブロックの仮引数リストの個数と型が決まります。
ブロックをどう使うかはブロック引数を渡す関数が決める事です。 rsumの場合は各範囲の値を順番に仮引数リストに入れてブロックを実行し、結果を全て足し合わせたものを返します。 足し合わせるのはrsumの機能で、reduce関数などはまた違った使い方をします。
現時点ではブロックは引数としてしか使う事は出来ません。
rsumの次元
rsumは1次元と2次元の範囲をサポートしています。 どちらも結果は単一の値になります。 これはtensor reduceとは異なる所です。
次元はrsumの引数の範囲の個数で決まります。 また、ブロック引数の仮引数リストはrsumの次元に合った個数でなくてはいけません。
let sum = rsum(0..<3, 0..<3) | i, j | {
i*3+j
}
reduce関数
reduceにはreduce関数とtensor reduceで使われるreduceの2つがありますが、ここでは単体のreduce関数の方を扱います。
reduceという言葉はMFGでは、次元を減らす操作に対して使われる言葉です。 これはtransとreduceで様々なreduceや、対応するtransという概念を見るとはっきりするので、 この時点ではあまり深く立ち入らず、単なる関数名だと思っておいてください。
reduce関数はrsumより高機能で、その分複雑です。rsumで出来る事は全てreduceでも実現出来ます。
reduce関数は、以下のように使います。
let sum = reduce(init=1, 0..<4) | index, accm | {
accm+index*2
}
accmは最初はinitの値が入り、以後は一つ前のブロックの値が入る事になります。 この例では、ループはindexが0から3までの4回実行され、それぞれのaccmは以下のようになります。
- accm: 1
- accm: 1
- accm: 3
- accm: 7
そして最後の値としては13となり、sumには13が入ります。
reduce関数のシンタックス
reduce関数は以下のようなシンタックスとなります。
# 1次元の場合
reduce(init=INITの値, 範囲1) |i, accm| {...}
# 2次元の場合
reduce(init=INITの値, 範囲1, 範囲2) |i, j, accm| {...}
i, j, accmは任意の名前で構いません。 accmはaccumulatorの略として良く使われます。
i, jにはループのインデックスが入り、accmには前回のブロックを実行した結果の値が入ります。初回の値はinitで指定した値が入ります。 そして最後に実行したブロックの結果がこの関数自体の結果となります。
ブロックの仮引数の順番はインデックスが先でaccmはいつも最後です。
reduceの結果の型はinitで指定した値の型と同じになります。 ブロックを評価した結果の型はinitと同じ型でないといけません。 accmはinitと同じ型となります。
initの値としてはタプルを使う事も出来ます。その場合accmにもタプルが入ります。
reduceのブロックの実行順序
reduceは前のブロックの結果をaccmで受け取る、という性質上、 ループをどういう順序で実行するかに依存するものとなっています。
2次元の場合の以下の例を考えてみます。
let sum = reduce(init=1, 0..<3, 0..<3) | i, j, accm | {
accm+i*3+j
}
この場合、実行される順序としては、jが外側のループ、iが内側のループとなるように実行されます。
具体的にはこのケースだと、以下のindexの順序という事です。
- i:0, j:0
- i:1, j:0
- i:2, j:0
- i:0, j:1
- i:1, j:1
- i:2, j:1
- i:0, j:2
- i:1, j:2
- i:2, j:2
ts.sum
テンソルを元としたループは、transとreduceで詳しく扱いますが、 ts.sumはrsumと似た機能なのでここで見ておきます。
ts.sumはテンソルの各要素に対してブロックを実行していき、結果を合計したものを返します。
def weight by [
[1.0, 2.0, 1.0],
[2.0, 3.0, 2.0],
[1.0, 2.0, 1.0]
]
let weightSum = weight.sum |i, j, elem| { elem }
i, jにはテンソルの各インデックスが入り、elemにはその点でのテンソルの値が入ります。 i, jの型はi32、elemはこの場合f32となります。
この場合はiとjは使っていないのでアンダースコアでも構いません。
def weight by [
[1.0, 2.0, 1.0],
[2.0, 3.0, 2.0],
[1.0, 2.0, 1.0]
]
let weightSum = weight.sum |_, _, elem| { elem }