OpenMP を使って Android アプリを高速化してみた ~C++ネイティブコードのマルチスレッド化でハマったポイント~

こんにちは。ウェブテクノロジの清水です。

最近はAndroid機のCPU性能もどんどん上がり、搭載メモリ容量も増えてきているので、いわゆる非常に重い演算処理を行うプログラムでも、もうAndroid機の上で実用レベルの速度で動かせるのでは?と考えている人も多いと思います。

そんな要望を満たしてくれそうなものがAndroid NDKによるC++ネイティブコード開発環境と、マルチコアCPUを前提とした高速化が簡単に行えるOpenMP技術です。

今回は、Android NDKとOpenMPを組み合わせて使用した時に遭遇した問題と、その解決方法のお話です。

 Android機では処理が重すぎた

とある目的で、ちょっと処理の重い画像処理プログラム(C++ネイティブコードで2000行程度のシングルスレッド処理)を開発し、最初にデスクトップPC(Core-i7)のWindows上で動かすと約3秒で処理が終わりました(処理対象の画像の大きさは2000×1500ドット程度)。

このプログラムをAndroid NDKを使用してAndroidアプリにC++ネイティブコードのまま移殖し、Nexus7(2012)で動かすと約30秒もかかってしまいました(Releaseビルド)。

これでは、さすがに使う人が嫌がってしまいます。最近のAndroid機はNexus7(2012)より高性能のものが多いと思いますが、それでも消費電力の大きなデスクトップPCのCPUと比較すると性能差は歴然です。

この画像処理プログラム側は、もともと大量の演算を行っており、アルゴリズムの都合上、演算量自体を大幅に減らすのは困難でした。そこで、画像処理プログラムをマルチスレッド対応にして高速化することにしました。

最近はAndroid機のCPUのマルチコア化も進んでおり、すでに旧機種となったNexus7(2012)でも4コアのCPUを搭載していますので、これを最大限に活用することにします。

簡単にマルチスレッド化して高速化ができるOpenMP

この画像処理プログラムではピクセル単位で処理を行っており、他のピクセル処理との独立性が高いため、OpenMP技術を使用して比較的簡単に高速化が可能です。

たとえば、次のような典型的な構造の画像処理プログラムでは、

    for (int y = 0; y < height; y++)
    {
        for (int x = 0; x < width; x++)
        {
            // ピクセル毎の画像処理
        }
    }

下記のように外側のfor文の最初にOpenMP用の指定を追加することで、コンパイラが自動的にマルチスレッド実行用のコードを生成し、表1のように各スレッド毎に担当するループ範囲を自動で割り振って高速化してくれます。

    #pragma omp parallel for              // ←これを追加
    for (int y = 0; y < height; y++)
    {
        for (int x = 0; x < width; x++)
        {
            // ピクセル毎の画像処理
        }
    }

表1:

heightが1000のときのループ分割例(4コアCPUの場合)
 スレッド1 for (int y =   0; y <  250; y++)
 スレッド2 for (int y = 250; y <  500; y++)
 スレッド3 for (int y = 500; y <  750; y++)
 スレッド4 for (int y = 750; y < 1000; y++)

Android NDKのC++コンパイラもOpenMPに対応しており、「Android.mk」でコンパイラオプションに「-fopenmp」を追加することで簡単にOpenMP技術を使用することが可能です。

並列実行する処理のコードは、OpenMPの制限に合うように多少書き換えが必要になる場合もありますが、今回の画像処理プログラムでは、大部分の処理が無変更でOpenMPに対応できそうでした。

OpenMP対応するとAndroid機で落ちる!?

最初にPC側の画像処理プログラムをOpenMP対応に書き換えてみると、書き換えが必要だった部分も少なく、また、十分に高速化の効果がありました。

ところが、Android機側のプログラムをOpenMP対応に書き換えてみると、処理を開始した途端に落ちてしまいました。

デバッガで追いかけると、どうもOpenMPのランタイム内部で落ちているようです。
どうやら、PC側(WindowsのVisualStudio)のOpenMPには無かった問題がAndroid NDKのOpenMPにはあるようです。

作成したアプリの構造は、最初は、図1のように、時間のかかる画像処理を別スレッドで動かしていました。

図1:最初のプログラム構造

 図1: 最初のプログラム構造

時間がかかる処理は別スレッドで実行し、メインスレッドがブロッキングされないようにしています。

この構造の状態で、処理速度は遅いものの正常に動作していました。ところが、C++ネイティブコードの部分を図2のようにOpenMP対応に書き換えると正常に動かなくなりました。

図2: うまく動かないプログラム構造

図2: うまく動かないプログラム構造

OpenMPに対応するためのソースコードの変更点についてはPC側で既にOpenMPでの動作を確認済みで、画像処理プログラムのソースコード上には問題は見当たりません。

しかもAndroid機側で落ちている場所は画像処理プログラム自体のコードではなく、OpenMPのランタイム内で、しばらくは原因が全く分かりませんでした。

Android NDKのOpenMPはメインスレッド以外からは使えない

Android側の画像処理プログラムを一時的にメインスレッドから呼び出すようにしてみると、アプリのUIはブロッキングされてしまうものの、ちゃんとOpenMPによるマルチスレッドで画像処理プログラムが高速化されていました。

最初に作成していたアプリは定石通りに時間がかかる画像処理プログラムを別スレッド化して実行する構造にしていました。ところが、OpenMPを使う場合は、この構造が逆に問題を引き起こしていたのです。

アプリが落ちる原因は、画像処理プログラム側のコードの問題でもなく、OpenMP対応のコード書き換えミスでもなく、メインスレッド以外から呼び出していた、という点だったのです。

 

ネットを検索してみると、日本語では有用な情報は見つからなかったものの、英文で探してみると同じ問題の報告例を見つけました。

やはり、Android NDKのOpenMPではメインスレッド以外からは使えないのです(Android NDK r9cで確認)。

OpenMPに対応したプログラムでは、並列実行処理の直前部分にOpenMPのランタイム呼び出しコードが挿入されていて、その部分でスレッド起動などの制御を行っているのですが、この部分の処理がメインスレッド以外から呼び出された場合に対応しておらず、実行時に落ちていたようです。

 

PC側で使用していたVisualStudioのOpenMPオプションでは、メインスレッド以外のスレッドからOpenMPを使用するコードを呼び出しても問題なく動作していたので、Android NDKのOpenMPオプションにこのような制限があることは、当初、まったく気付いていませんでした。

OpenMPで高速化したい処理は時間がかかる処理なので、メインスレッドから画像処理を行ってしまうとGUIをブロッキングして画面表示が止まってしまいます。

Androidには、処理をモジュール化して分離するためにServiceという機構がありますが、Serviceの起動はメインスレッドから行われます。そのため、やはり時間のかかる処理はGUIをブロッキングしてしまうので、結局、Serviceから別スレッドを起動して処理する必要があります。

GUIをブロッキングしない目的で別スレッド化していた画像処理をOpenMPで高速化するには、メインスレッドから呼び出すようにして、しかもGUIをブロッキングしないように実装する必要がある…どうすればそんなことが可能になるのでしょう?

開発した画像処理プログラムはアルゴリズムが複雑で、処理を細切れに分割して、それぞれを少しずつメインスレッドから呼び出してブロッキングしないように処理を進める、というような改造は困難でした。

別プロセスのメインスレッドを使う

OpenMPが正常に動作しない原因がメインスレッド以外からの呼び出しであることが分かったので、あとは対策を行うだけです。

アプリのメインスレッドはUIで使用するので、UIをブロッキングしてしまうほど時間がかかる処理をメインスレッドで行うのは不適切です。

メインスレッドで時間のかかる画像処理を行いつつ、メインスレッドのUIをブロッキングしない方法…そうです、別プロセスを起動して、そのメインスレッドで画像処理を行うしかありません。

好都合なことに、AndroidのService機構には別プロセスで起動する設定が用意されているので、これを使用することにしました。

Serviceを別プロセスで起動する設定

AndroidManifest.xmlで、
application → service → android:process= に、
「:」で始まるプロセス名を指定します。

つまり、正常に動かなかった図2のようなプログラム構造から、図3のようなプログラム構造に変更したのです。

図3: 問題なく動作したプログラム構造

図3: 問題なく動作したプログラム構造

これで、OpenMP対応に書き換えた画像処理プログラムも正常に動作し、実行時間もNexus7(2012)上で当初の約30秒から約7秒にまで短縮できました。

このくらいまで速くなると、処理中にはプログレスバーダイアログを出していますので、使うのが嫌になるほどの待ち時間ではなくなったと思います。

プログレスバーダイアログを制御するためのメインスレッドと別プロセスのServiceとのプロセス間通信にはMessengerを使用しました。

まとめ

最後に、今回のポイントを整理すると、このようになります。

Android アプリ開発に OpenMP を使用する場合のポイント

  • Android のアプリ開発でも、C++ネイティブコードでOpenMP を使うと簡単にマルチスレッド対応ができるので、重い処理の高速化に効果的。
  • しかし、Android では、OpenMP はメインスレッドからしか使えない。
  • 当然だが時間のかかる処理をメインスレッドで実行すると、その間、アプリのUIが停止してしまう。
  • したがって、Android アプリで OpenMP で重い処理をさせたい場合、別プロセスで Service を起動し、そちらで処理させる。

実は、このプログレスバーダイアログの実装でも問題が発生して一苦労したのですが、その話はまたの機会に。

以上、Android NDKでネイティブコードのOpenMP対応を検討している方々の参考になれば幸いです。

タグ , , , , | 2020/06/16 更新 |