はじめに
- 本記事はOpenCV Advent Calendar 2022 の24日目の記事です。
- 23日目の記事は@KazuhitoさんによるNiceGUI と OpenCV を組み合わせたいでした
- その他の記事は目次をご覧ください。
- カレンダーが埋まってないのがちょっと悔しいので小ネタで埋めることにしました。
TL;DR
- OpenCLのin-placeな使い方は、一部のカーネルでは対応していない
テストのFAIL
- もとはstitchingモジュールのFAILから始まりました。
[ RUN ] ExposureCompensate.SimilarityThreshold
/opencv/modules/stitching/test/test_exposure_compensate.cpp:69: Failure
Expected: (psnr_similarity_mask) > (300), actual: 38.0114 vs 300
[ FAILED ] ExposureCompensate.SimilarityThreshold (56824 ms)
- かいつまんで説明すると、stitchingモジュールの主な機能は2枚以上の画像を、うまいことつなぎ合わせる(stitch)ことです
- 今回のテストはその中でも2枚の画像の露光条件が違った場合に2枚の画像の見た目を揃える機能です
- テストで使う画像によって、類似度が300以上になってほしいのに、何故か38程度にしかなっておらず、degradedな状況に陥っています。
Expected: (psnr_similarity_mask) > (300), actual: 38.0114 vs 300
環境による切り分け
- 手元にあるOpenCV用のビルドファーム(AWSのJenkinsと、リモートに置いてあるJetson、Raspberry Piなどで構成される)で当該テストを見ると、きれいに「テストがPASSする環境」、「テストがFAILする環境」に二分されました
テスト結果 | ボード名 |
---|---|
FAIL | FireFly RK3399 |
FAIL | ODROID C4 |
FAIL | ODROID N2 plus |
FAIL | ODROID XU4 |
FAIL | Tinkerboard |
FAIL | ROCK Pi 4C |
PASS | Jetson Nano |
PASS | Jetson TX1 |
PASS | Jetson TX2 |
PASS | Jetson AGX Xavier |
PASS | Jetson Xavier NX |
PASS | le potato |
PASS | Raspberry Pi 3 |
PASS | Raspberry Pi 4 |
- 個人的な感想ですが、テスト環境は多ければ多いほど好ましいです
- デバッグは面で行う。これ大事です。
- 今回もこの結果を見ただけで原因に心当たりが付きますが、それだとアドベントカレンダーの意味がないので、もうちょっと解説しましょう。
- こけてる環境はすべてOpenCLを実行できるGPUを持っていました
ボード名 | SoC | GPU |
---|---|---|
firefly-rk3399 | RK3399 | Mali-T860 OpenCL 1.2 |
odroid-c4 | S905X3 | Mali-G31 OpenCL 2.0 |
odroid-n2-plus | S922X | Mali-G52 OpenCL 2.0 |
odroid-xu4 | Exynos5422 | Mali-T628 OpenCL 1.2 |
rock-pi-4c | RK3399 | Mali-T860 OpenCL 1.2 |
tinkerboard | RK3288 | Mali-T760 OpenCL 1.2 |
- 一方でPASSした奴らはOpenCLを実行できるGPUを持っていません
- また、OpenCLの実行に関しては4年前の記事でデバイスや環境変数などについて解説しました
- 当時も紹介した
OPENCV_OPENCL_DEVICE
環境変数を使って、原因がOpenCLカーネルのどこかであることも特定しました
$ OPENCV_OPENCL_DEVICE=:gpu ./opencv_test_stitching
(中略)
[ FAILED ] ExposureCompensate.SimilarityThreshold (56824 ms)
$ OPENCV_OPENCL_DEVICES=disabled ./opencv_test_stitching
(中略)
[ OK ] ExposureCompensate.SimilarityThreshold (93847 ms)
- 上のコマンドを解説すると、冒頭で
OPENCV_OPENCL_DEVICE
環境変数を設定しています - 1回目のテストはFAILし、2回目の実行ではテストがPASSします
- 1回目は
:gpu
という値を環境変数に設定し、これによりOpenCVは内部でGPUがOpenCLに対応しているか確認し、OpenCLがサポートされている環境であればGPUでカーネルを実行します - 2回目は
disabled
という値を設定し、この場合はOpenCLのカーネルは呼ばれず、CPU版の実行に限定されます
- 1回目は
- OpenCL版の実行でコケ、CPU版では問題なく動くということなのであとは両者の挙動がどこで発生するか二分探索で絞り込んでいくだけです。
発生場所の特定
- データに違いが発生するところを絞り込み続けると、最終的に以下の1行まで絞り込むことに成功しました
exposure_compensate.cpp
if (!similarities_.empty())
{
CV_Assert(similarity_it != similarities_.end());
UMat similarity = *similarity_it++;
bitwise_and(intersect, similarity, intersect); // ←このbitwise_andがおかしい
}
- ここでは配列になっている
similaritiy
の各要素にintersect
でマスク処理を行っています1 - ここが原因であることを補強するため、以下のようなコードも試してみました
if (!similarities_.empty())
{
CV_Assert(similarity_it != similarities_.end());
UMat similarity = *similarity_it++;
bool flag = cv::ocl::useOpenCL(); // 現状のフラグを一度退避
cv::ocl::setUseOpenCL(false); // OpenCLを一時的にdisable
bitwise_and(intersect, similarity, intersect); // カーネルを実行
cv::ocl::setUseOpenCL(flag); // OpenCLをもとの状態に復帰
}
- 前後に合計3行追記しました
-
cv::ocl::setUseOpenCL()
APIはtrue
かfalse
を取り、false
の場合はプラットフォームの対応状況に関わらず、OpenCLのカーネル実行を取りやめます - 前述の
OPENCV_OPENCL_DEVICE=disabled
を実行した場合も、内部的にはcv::ocl::setUseOpenCL(false)
を呼んだことと等価です - また、
cv::ocl::getUseOpenCL()
によって現状のOpenCL使用の可否の状況を退避してあるので、このあとの処理に関しても影響が及ばないようになっています - 詳しくはdandelion1124先生が書いたUMatの内部処理でも解説されています
-
- つまりこのカーネルをGPUで実行するとなにか予期しない結果になる、と結論づけられます
後日談というか今回のオチ
- この関数の中を追わないといけないのかと思ってたのですが、OpenCV公式メンバの一人、alalek先生から以下のコメントが付きました
OpenCL kernels for operations with "in-place" src/dst buffers should be implemented with another kernels code. This is a bug to be fixed in core module.
- 意訳すると
- 「OpenCLの"in-place"な操作(src/dstが同一な状況)は別のカーネルとして実装されるべきです。これはcoreモジュール内で修正されるべきバグです」
- とのことでした
- もう少しこの"in-place"について説明すると、この
bitwise_and
関数は第1引数が入力、第3引数が出力です - このとき、第1引数と第3引数は同じポインタですので、同じ番地を指します
bitwise_and(intersect, similarity, intersect);
- さて、OpenCVからOpenCLのカーネルを呼ぶとき、OpenCLへパラメータを渡す必要がありますが、以下のようにポインタは「書き込み専用」「読み込み専用」「読み書き両方許可」の3種類から属性を選んでポインタとともに渡すことができます
- 以下は実際に到達する
ocl_binary_op
関数内の実装です
modules/core/src/arithm.cpp
ocl::KernelArg src1arg = ocl::KernelArg::ReadOnlyNoSize(src1, cn, kercn);
ocl::KernelArg dstarg = haveMask ? ocl::KernelArg::ReadWrite(dst, cn, kercn) :
ocl::KernelArg::WriteOnly(dst, cn, kercn);
-
haveMask
はfalse
なので、dstarg
にはocl::KernelArg::WriteOnly(dst...)
が代入されます- この
WriteOnly
はポインタとその属性、今回は書き込み専用というフラグ、それから幅の情報などを含めて一つのクラスに変換する関数です
- この
- 一方で、
src1arg
にはocl::KernelArg::ReadOnlyNoSize(src1...)
という値が設定されます- 今度は
ReadOnlyNoSize
という名前の通り、ポインタは読み込み専用として渡されます
- 今度は
- 一見問題がないように見えますが、同一ポインタが2種類相反するフラグで渡された場合、確かになにか問題が起きそうな気がします
- ここから先はOpenCLの仕様を調べればわかるかもしれませんが、割愛します
- 憶測で書けば、「同一ポインタを別フラグで渡された場合の挙動」は実装依存なのではないかと想像します。つまり読み込み専用フラグが優先されるか、書き込み専用フラグが優先されるか、それとも自動的に読み書き両方許可になるのか、そこが実装依存なんだと思います。今回は「読み込み専用」のフラグが優先され、処理は実行されたがメモリに書き戻しが行われず、結果テストFAILにつながったように見えます。(憶測ここまで)
- というわけで、今回は以下のように迂回策を入れました
// in-place operation has an issue. don't remove the swap
// detail https://github.com/opencv/opencv/issues/19184
Mat_<uchar> intersect_updated;
bitwise_and(intersect, similarity, intersect_updated);
std::swap(intersect, intersect_updated);
-
intersect_updated
というバッファを明示的に事前に設け、カーネル実行直後にstd::swap
で参照を入れ替えました2
おわりに
- OpenCVの4.5.1に含まれるバグを紹介しました。
- バグが混入したのが4.5.1リリース前、バグfixが4.5.2リリース前なのでリリースされたもののうち、4.5.1だけが影響をうけるバグです
- また、
OPENCV_OPENCL_DEVICE=disabled
を指定すれば迂回できます
- 約2年前のネタを投稿している時点で結構ネタ切れ感が半端ないですが、カレンダーを埋めるために投稿しました。
- 明日はとうとう最終日です。