■ 2023年06月04日 [OpenGL][GLSL][コンピュートシェーダ] セパラブルフィルタ
ブログ書いてない
2019 年から丸 4 年ブログを更新しておりませんでした。もちろん、その間に何もしていなかったわけではなく、また書きたいと思っているネタもいくつもありました。でも、なんか毎日が自転車操業状態で、ブログを書くとか、それより大事な論文を書くとかいう作業に全然取り掛かれずにいました。コロナとかでいろいろあったってこともあるんですけど、この状況で逆に研究に集中できたという方もいらっしゃいますから、言い訳にはならんでしょうね。今もやらなきゃなんないことがいくつもあって、忙しい、時間が足りないという焦りで頭が一杯になっています。
でも、これは錯覚に過ぎないっていう話も聞きます。そういうことを ChatGTP に相談してみたりしたのですが、まあ、そのとおりやなっていう回答をもらったものの、気づきみたいなものは得られませんでした。もっと突っ込んで質問すればよかったのでしょうけど、それ以上聞きたいことを思いつきませんでした。
コンピュートシェーダのプログラミングモデル
GPU のプログラミングモデルは、大量のデータを GPU の複数のプロセッサに分配し、各プロセッサはカーネルと呼ばれる共通したプログラムにより、分配された個々のデータに対して同一の処理を同時に実行するというものです。このような処理はデータ並列性 (data parallelism) と呼ばれます。
特に GPU では、分配されたデータが互いに依存していないとして、個々のプロセッサが他のプロセッサの実行状況を考慮することなく、並列に処理を実行します。バーテックスシェーダは CPU から供給された複数の頂点の頂点属性を同時に処理しますが、一つのプロセッサは一つの頂点だけを担当し、他の頂点の頂点属性を参照することはありません。フラグメントシェーダもラスタライザにより指定されたフラグメントのみを担当し、他のフラグメントの状況を考慮することはありません。これにより、各プロセッサは他のプロセッサの状況にかかわらず、フルスピードで動作することができます。これはストリームコンピューティング (stream computing) と呼ばれます。
しかし一般には、入力された複数のデータが相互に依存しているような処理はいくらでも存在します。例えば画像のフィルタリング処理では、入力画像の複数の画素のデータから出力画像の1画素の値を決定します。
このような応用に対応するために、コンピュートシェーダには複数のプロセッサがデータを共有する機能や(共有メモリ)、共有したデータに対する複数のプロセッサからのアクセスの競合を調停する機能(アトミック変数)、およびプロセッサが互いに処理の同期をとる仕組み(バリア)などが導入されています。また、プロセッサを入力データに対してどのように配分するかという設定も、プログラマに提供されています。
コンピュートシェーダでセパラブルフィルタ
今回は 2018 年に書いたコードをネタにします。ずっと書きたかったのですが、先に書いた通り書くことがとっても億劫だったので、先延ばしにしていました。でも最近学生さんに説明する必要が出てきたので、いい加減にちゃんと書こうと思いました。元ネタは NVIDIA の(OpenGLの始祖であらせられる)Mark Kilgard 氏の 2012 年の SIGGRAPH での講演、NVIDIA OpenGL for 2012 です。
画像のフィルタリング処理では、例えば、画像中の \(\left(x,y\right)\) の位置にある画素の値 \(f\left(x,y\right)\) を、その位置を中心とした \(\left(2N+1\right)\times\left(2N+1\right)\) 画素の近傍の画素の平均値 \(h\left(x,y\right)\) で置き換えることにより、画像をぼかすことができます。
$$D=\left(2N+1\right)^2$$
$$h\left(x,y\right)=\frac{1}{D}\sum_{j=-N}^{N}\sum_{i=-N}^{N}f\left(x+i,y+j\right)$$
この計算を横 \(W\) 画素、縦 \(H\) 画素の画像に対して画素ごとに独立に行うと、画素へのアクセス回数は(周辺部のはみ出しを無視するとして)\(\left(2N+1\right)^2\times W\times H\) 回になります。しかし、これは次のように、列ごとの和を求めてから、それを行ごとに合計しても同じです。
$$g\left(x,y\right)=\sum_{i=-N}^{N}f\left(x+i,y\right)$$
$$h\left(x,y\right)=\frac{1}{D}\sum_{j=-N}^{N}g\left(x,y+j\right)$$
このとき、この \(g\left(x,y\right)\) を画像全体について保持しておけば、その分のメモリが必要になりますが、画素へのアクセスが \(2\left(2N+1\right)\times W\times H\) 回になるので、計算量のオーダーを下げることができます。
これはガウスフィルタのような重み付きフィルタの場合も同様です。
$$D=\sum_{j=-N}^{N}\sum_{i=-N}^{N}\exp\left(-\frac{i^2+j^2}{2\sigma^2}\right)$$
$$h\left(x,y\right)=\frac{1}{D}\sum_{j=-N}^{N}\sum_{i=-N}^{N}\exp\left(-\frac{i^2+j^2}{2\sigma^2}\right)f\left(x+i,y+j\right)$$
この各画素の重みは、
$$\exp\left(-\frac{i^2+j^2}{2\sigma^2}\right)=\exp\left(-\frac{i^2}{2\sigma^2}\right)\exp\left(-\frac{j^2}{2\sigma^2}\right)$$
ですから、\(j\) の項をくくり出して、
$$h\left(x,y\right)=\frac{1}{D}\sum_{j=-N}^{N}\exp\left(-\frac{j^2}{2\sigma^2}\right)\sum_{i=-N}^{N}\exp\left(-\frac{i^2}{2\sigma^2}\right)f\left(x+i,y+j\right)$$
とすると、これも次のように2つのステップに分けることができます。
$$g\left(x,y\right)=\sum_{i=-N}^{N}\exp\left(-\frac{i^2}{2\sigma^2}\right)f\left(x+i,y\right)$$
$$h\left(x,y\right)=\frac{1}{D}\sum_{j=-N}^{N}\exp\left(-\frac{j^2}{2\sigma^2}\right)g\left(x,y+j\right)$$
ワークグループとスレッド
さて、このような処理をコンピュートシェーダ上に実装するわけですが、そのためにはまず、コンピュートシェーダのプロセッサを、入力データに対してどのように配分するかを決めておく必要があります。
GPU には多数のプロセッサがあり、それぞれが並列に動作します。この一つのプロセッサに割り当てられた処理をスレッド (thread) といいます。そして、互いに協力して働くことのできるスレッドの集合をワークグループ (workgroup) といいます。一つのワークグループを構成するスレッドの数は、あらかじめ決めておく必要があります。これは (local_size_x, local_size_y, local_size_z) の 3次元の格子の数で指定します。
またコンピュートシェーダを起動する際は、このワークグループをいくつ使用するかを指定します。このワークグループの数も (num_groups_x, num_groups_y, num_groups_z) の3次元の格子の数で指定します。ここでは対象が画像なので、local_size_z = num_groups_z = 1 として、2次元の格子を構成するようにします。
ワークグループ同士も、互いに並行して動作します。これらがどれだけ並列に動作するかどうかは、GPU のプロセッサの数などのリソース次第です。世の中金次第ということですね。
コンピュートシェーダによる画像のフィルタリング
いま、入力画像 image の (x,y) の位置にある画素を中心とした横 filterSize.x、縦 filterSize.y の領域 filter 内の各画素の値から、入力画像と同じサイズの出力画像 filtered の (x,y) の位置にある画素の値を決定することを考えます。これを出力画像のすべての画素に対して行います。
ですが、これを一つ一つの画素に対して順番にやっていては、CPU で処理するのと変わりありません。そこで、複数の画素の値を同時に決定するように、処理を並列化します。ワークグループ内の各スレッドは並行して動作しますから、それぞれに値を決定する画素を割り当てます。ここでは一つのワークグループが処理する画素の領域を tile と呼ぶことにします。この tile の横と縦のサイズを、それぞれ tileSize.x、tileSize.y とします。
コンピュートシェーダは、この tile を画像に敷き詰めていると考えて実行します。ここでは、この一つの tile に対して一つのワークグループを割り当てます。
個々のワークグループにはワークグループの番号が割り当てられ、組み込み変数 gl_WorkGroupID に格納されます。ワークグループは3次元の格子に配列されていると見立てていますから、gl_WorkGroupID も3次元のベクトルです。同様にワークグループ内の個々のスレッドにもスレッドの番号が割り当てられ、組み込み変数 gl_LocalInvocationID に格納されます。これも3次元のベクトルです。これらの番号は、各スレッドが担当する対象(画素など)を決定するための手掛かりに用いられます。
一つの tile が参照する入力画像の領域のサイズは、tile の最外周の画素に filter の中心を置いた時の、filter の範囲を含む領域です。filter の中心の画素から最外周の画素までの画素数 \(N\) は、横方向と縦方向それぞれ filterOffset.x = floor(filterSize.x / 2)、filterOffset.y = floor(filterSize.y / 2) ですから、これは横が neighborhoodSize.x = tileSize.x + filterOffset.x×2、縦が neighborhoodSize.y = tileSize.y +filterOffset.y×2になります。このサイズの作業用の配列を共有メモリに作成します。フィルタリングの処理は、画像をこの共有メモリにコピーして実行します。
ワークグループとタイルのサイズ
さて、ここからがコンピュートシェーダのキモです。いま、このフィルタリングの実行に使うワークグループのサイズを、横 tileSize.x、縦 neighborhoodSize.y とします。つまり、横は tile の横のサイズと同じですが、縦は tile の縦のサイズに filter の縦方向のマージン分を加えたものにします。このサイズを用いる理由は後述します。ここではワークグループのサイズを横 local_size_x = 32、縦 local_size_y = 32 とし(local_size_z は省略しているので 1 になります)、filter のサイズ filterSize を横 filterSize.x = 11、縦 filterSize.y = 11 として、これらからタイルのサイズ tileSize を決めることにします。
#version 430 core // 非 0 ならガウスフィルタ、0 なら平均値(ボックス)フィルタ #define GAUSS 1 // ワークグループのサイズ layout (local_size_x = 32, local_size_y = 32) in; // フィルタのサイズ const ivec2 filterSize = ivec2(11, 11);
filter のマージン filterOffset を求めておきます。正の整数の除算なので、小数点以下は切り捨てられます。これはガウスフィルタの重みを計算するときに filter の中心位置としても使います(このため filterSize は奇数である必要があります)。
// フィルタのオフセット const ivec2 filterOffset = filterSize / 2;
そうすると、一度に処理するタイルの領域 tile のサイズ tileSize は、ワークグループのサイズの縦方向を filter のマージン分切り詰めたものになります。ワークグループのサイズが横 local_size_x = 32、縦 local_size_y = 32、フィルタ filter の縦のサイズが filterSize.y = 11 であれば、タイルのサイズ tileSize は横 tileSize.x = 32、縦 tileSize.y = 32 – floor(11/2) × 2 = 22 になります。
// 一度に処理するタイルの領域のサイズ const ivec2 tileSize = ivec2(gl_WorkGroupSize) - ivec2(0, filterOffset.y * 2);
また、tile のサイズに filter のマージンを加えて、tile が参照する入力画像の領域のサイズを求めます。
// 近傍を含む領域のサイズ const ivec2 neighborhoodSize = tileSize + filterOffset * 2;
このサイズの配列 pixel を、共有メモリ上に作成します。これに加えて、横のサイズが tile と同じサイズの vec2 の配列 row も作成しておきます。平均値(ボックス)フィルタやガウスフィルタでは、事前に重みの総和 \(D\) を求めることができますが、ここでは後々の都合のために row を vec2 にして、分子と分母のそれぞれの合計を別々に求められるようにしておきます。
// 処理する領域の近傍を含めたコピー shared float pixel[neighborhoodSize.y][neighborhoodSize.x]; shared vec2 row[neighborhoodSize.y][tileSize.x];
入力画像のフォーマットは、この後の都合から r32f (32bit float, 1 channel) とします。これを image というイメージユニットから入力します。結果は同様に r32f の filtered というイメージユニットに格納します。このほか、ガウスフィルタの重みの分散を unform 変数で取得します。
// 入力データを格納したイメージユニット layout (r32f) readonly uniform image2D image; // 出力データを格納するイメージユニット layout (r32f) writeonly uniform image2D filtered; // ガウスフィルタの重みの分散 (x: column, y: row, z: value) uniform vec2 variance;
イメージユニットから画素を取得する際、インデックスがイメージユニットの範囲から外れないようにする関数 clampLocation() を用意しておきます。
// インデックスがイメージの領域から外れないようにする ivec2 clampLocation(ivec2 xy) { return clamp(xy, ivec2(0), imageSize(image) - 1); }
スレッドの同期をとる関数 retirePhase() を用意します。これは共有メモリへのアクセスの完了と、スレッドのここまでの処理の完了を待ちます。
// 他のスレッドの共有メモリへのアクセス完了と処理完了を待つ void retirePhase() { memoryBarrierShared(); barrier(); }
以降、実際の処理を説明します。コンピュートシェーダはバーテックスシェーダやフラグメントシェーダと異なり、処理する対象を自分で決めることができます。しかし、適切な対象を決めるためには、何らかの手掛かりが必要になります。そのために、ワークグループやスレッドに割り当てられる番号を用います。前者は組み込み変数 gl_WorkGroupID、後者は組み込み変数 gl_LocalInvocationID に格納されています。
void main(void) { // ワークグループが処理する領域の基準位置 const ivec2 tile_xy = ivec2(gl_WorkGroupID); // スレッドが処理する画素のワークグループにおける相対位置 const ivec2 thread_xy = ivec2(gl_LocalInvocationID);
これらより、スレッドが担当する入力画像中の画素の位置 dst_xy を求めます。ワーク区グループの番号に tile のサイズを乗じて、それにスレッドの位置を足します。これは結果を格納するイメージユニットの画素の位置です。
// スレッドに割り当てられた画素位置 const ivec2 dst_xy = tile_xy * tileSize + thread_xy;
スレッドが参照するイメージユニット image の領域の基準の画素位置は、格納する画素位置より filter の縦方向のマージン分下げたところにします。
// スレッドが読み出すイメージ上の画素位置 const ivec2 src_xy = ivec2(dst_xy.x, dst_xy.y - filterOffset.y);
x, y はスレッドが処理するタイル上の画素位置です。
// スレッドが処理する画素位置 const uint x = thread_xy.x; const uint y = thread_xy.y;
スレッドが参照するイメージユニット image の領域の各画素を、共有メモリ上の配列 pixel にコピーします。読み出す画素の位置の決定に先ほど定義した clampLocation() を使います。これにより image からはみ出した部分から読み出そうとしたときは、image の最外周の画素の値になります。これでテクスチャの GL_CLAMP_TO_EDGE に似た処理になります。
ワークグループの各スレッドは、自分がコピーする画素を1つ共有メモリ上の配列 pixel にコピーします。このコピーは並列に動作する多数のスレッドによって一気に実行されます。データ並列性というのは、逐次処理では繰り返しループで実行されていた複数のデータに対する処理を、このように多数のプロセッサによる並列処理に置き換えます。
ただし、ここで本当に1つの画素しかコピーしないでいると、filter の横のサイズ filterSize.x 分の画素がコピーされません。そこで、ワークグループの横のサイズ tileSize.x 分ずらした位置にある画素をもう一度コピーします。これを共有メモリ上の配列 pixel の横のサイズを超えない範囲で繰り返します。
// タイルごとに処理する1画素を共有メモリにコピーする for (int i = 0; i < neighborhoodSize.x; i += tileSize.x) { if (x + i < neighborhoodSize.x) { // 読み出し位置 const ivec2 read_at = clampLocation(ivec2(src_xy.x - filterOffset.x + i, src_xy.y)); // 共有メモリに書き込む pixel[y][x + i] = imageLoad(image, read_at).r; } }
そして、次の処理に移る前に、すべての画素のコピーが完了し、すべてのスレッドが次の処理に移ることができる次点に到達するのを待つ必要があります。これは、最初に pixel の左端の画素を担当したスレッドは複数の画素のコピーを行う必要があるのに対し、右端の画素を担当したスレッドは1つの画素しかコピーする必要がないために、すべてのスレッドの処理時間が同じにはならないためです。また、スレッドが本当に並列に動作している独立したプロセッサに割り当てられているかどうかも、GPU の仕様に依存します。
// 他のスレッドの共有メモリへのアクセス完了と処理完了を待つ retirePhase();
セパラブルフィルタ
セパラブルフィルタでは、処理を縦と横に分離して行います。そのため、横方向の処理に必要なスレッドの数(ワークグループのサイズ)は、横は tileSize.x ですが、縦はフィルタの縦方向のマージン分を含めた neighborhoodSize.y になります。また、この結果を使って次に縦方向の処理を行うので、結果を保存するために必要なもう一つの共有メモリ上の配列 row のサイズは、このワークグループのサイズと同じ横 tileSize.x、縦 neighborhoodSize.y になります。
変数 x と y は、ワークグループ内でのこのスレッドの「位置」に相当します。そこからフィルタの横のサイズ filterSize.x 分の画素の重み付け和を求めます。sum は vec2 型なので、第1要素 (sum.r) に分子となる画素値の重み付け和、第2要素 (sum.g) に重みの和を求めます。重みの和は事前に計算できるので、ここでこんな風に計算する必要はないのですが、今後この部分にちょっと細工するつもりなので、今はこうしておきます。
// 対象画素の値に重みを掛けたものの合計(分子)と重みの合計(分母) vec2 sum = vec2(0.0); // 横方向の重み付け和を求める for(int i = 0; i < filterSize.x; ++i) { const float c = pixel[y][x + i]; #if GAUSS const float d = float(i - filterOffset.x); const float e = exp(-0.5 * d * d / variance.x); sum += vec2(c, 1.0) * e; #else sum += vec2(c, 1.0); #endif }
横方向の画素値の重み付け和と重みの和が求まれば、これを共有メモリ上の配列 row に格納します。そのあと、他のスレッドの共有メモリへのアクセスの完了と、ここまでの処理の完了を待ちます。
// 横方向の重み付け和を保存する row[y][x] = sum; // 他のスレッドの共有メモリへのアクセス完了と処理完了を待つ retirePhase();
次に、横方向の画素値の重み付け和と重みの和を縦方向に合計します。この処理には y が tileSize.y より小さいスレッドだけを使います。残りのスレッドの処理はここで完了します。
共有メモリ上の配列 row から横方向の画素値の重み付け和と重みの和を取り出し、その両方に縦方向の重みを乗じて、それぞれを合計します。
// tile の高さ分の数のスレッドを使って(そのほかのスレッドは終了) if (y < tileSize.y) { // 対象画素の値とその重みのペアを作る vec2 sum = vec2(0.0); // 縦方向の重み付け和を求める for (int j = 0; j < filterSize.y; ++j) { const vec2 c = row[y + j][x]; #if GAUSS const float d = float(j - filterOffset.y); const float e = exp(-0.5 * d * d / variance.y); sum += c * e; #else sum += c; #endif }
横方向の画素値の重み付け和と重みの和を縦方向に合計したら、画素値の重み付け和を重みの和で割って、出力画像 filtered の対応する画素に格納します。
// 分子を分母で割って保存する imageStore(filtered, dst_xy, vec4(sum.r / sum.g, 0.0, 0.0, 1.0)); } }
コンピュートシェーダの起動
このコンピュートシェーダは、次のようにして起動します。
/// /// 計算を実行する /// /// @param width 画像の横の画素数 /// @param height 画像の縦の画素数 /// @param tile_size_x タイルの横方向のサイズ /// @param tile_size_y タイルの縦方向のサイズ /// void execute(GLuint width, GLuint height, GLuint tile_size_x = 1, GLuint tile_size_y = 1) const { glDispatchCompute((width + tile_size_x - 1) / tile_size_x, (height + tile_size_y - 1) / tile_size_y, 1); }
(まだ追記・修正するかもしれん)
はじめまして.茨城大学の乾と申します.先生のHPはちょくちょく参考にさせていただいています.本当にありがとうございます.ちなみに私もあと数年で退職です.<br>さて,一点教えていただきたい点がありコメントさせていただきます.<br>オフスクリーンレンダリングをするソフトを作っています.GLEWを利用してFBOを定義し,そこにOpenGLで描画すること処理を実装しており,うまく動いています.ただGLEWを使う場合,オフスクリーンレンダリングであってもウィンドウは生成する必要があり,それがちょっと問題になっています.ウィンドウを全く生成しないでFBOを定義することは可能でしょうか?<br>ちなみにhttps://corgi-lab.com/programming/desc-opengl-without-window/を参考に,Windowsのレンダリングコンテキストを使ったオフスクリーンレンダリングも試してみました.こちらはウィンドウを生成しないOoenGL描画に成功したのですが,どうもGPUがうまく動作していないようです.こちらについても何か情報があればいただけると嬉しいです.<br>以上お願いばかりで恐縮ですが,お暇な時でけっこうですので,よろしくお願いします.
乾先生、コメントありがとうございます。ご著書がどこかにあったはずだと思って今探してみたのですが、どこかに埋もれてしまっているようで見つかりませんでした。すみません。<br><br>この件、私も調べてあれこれやろうとしたのですが、少なくとも GLFW ではできませんでした。ご存じの通り Windows が「正式に」サポートしている OpenGL のバージョンは 1.1 で、それ以降のバージョンは Installable Client Driver (ICD) という仕組みで GPU ベンダーが提供するドライバから組み込まれます。その際、読み込む ICD を決定するために D3D によってアダプタ (GPU) の情報を取得するのですが、そのためにその GPU で実際にウィンドウを開いているようです。したがってウィンドウを開かない状態ではドライバの API がメモリ空間に存在せず、wglGetProcAddress() が NULL を返します。<br><br>https://learn.microsoft.com/en-us/windows-hardware/drivers/display/loading-an-opengl-installable-client-driver
それで、どうしてもヘッドレスで使う必要がある場合は、ディスプレイを偽装する HDMI のアダプタ (ダミープラグ) を使うようです。<br><br>https://www.switch-science.com/products/7604
なるほど.なるほど.よく分かりました.ご指導ありがとうございました.レンダリングコンテキストを使った処理でGPUがうまく使えないわけも理解しました.その上で,もう一点教えてください.Windowsを使う場合にはウィンドウレスの処理が無理と分かったのですが,それではLinuxを使う場合はどうでしょう.今回の私の研究室のクライアントはLinuxベースでの利用が最終目標なのですが,Linuxの場合にウィンドウレス+GPU処理できるOpenGLの使い方は可能性はありますでしょうか?<br><br>蛇足ですが以前「GPU並列図形処理入門 ~CUDA・OpenGLの導入と活用」という本を書いたことがあります.あまり売れなかったのですが,床井先生にご購入いただけたのでしたら本当に光栄です.
乾先生、間が空いてしまって申し訳ありません。X11 でプログラミングすることは最近はほとんどないので忘却の彼方なのですが、Xorg を起動していれば、こういう手順でできないでしょうか。コンパイルは通ることは確認しましたが、ちゃんと動くかどうかまでは見ていません。<br>https://gist.github.com/tokoik/ce9603325262ef1c6777db408f173988
あと、先ほど調べていて見つけたのですが、GLFW で<br>glfwWindowHint(GLFW_VISIBLE, GLFW_FALSE);<br>を設定して見えないウィンドウを開くという方法もあるみたいです。<br>https://www.glfw.org/docs/latest/context_guide.html#context_offscreen