graphics.hatenablog.com

技術系テクニカルアーティストのあれこれ

PySide ウィンドウに大量の画像を表示する


そういや PySide でスレッド周りあんまやったことないなぁ……と思ってやってみた。
お題はアセットブラウザとかでよくあるサムネ画像の一覧表示。
おおよそ悪くないとこまでいけたので、主にパフォーマンス周りについてメモっておく。

コードはこちら → py3_test/thread_pool_test.py at master · hal1932/py3_test · GitHub

概要

  • テスト用に生成した 1000 枚の 1K 画像を、動的に 100x100 のサムネ化して一覧表示する。
  • 上記を可能な限り*1高速化する。

本来ならざっくり書いたあとにプロファイルをとるべきなんだけど、アセットブラウザなんてこれまで何度も書いてるので省略。でかいボトルネックは以下の 4 点。

  1. 画像ファイルの読み込み
  2. 読み込んだ画像の縮小
  3. GUI リソースの作成
  4. 描画

ただし「描画」に関しては Model/View を使えば比較的簡単に解決できてしまう*2ので、今回はそれ以外をマルチスレッディングで解決してみる。

「読み込み」は基本的に I/O bound なので、素直にサブスレッドでまわしてあげればいい。「縮小」と「オブジェクト作成」が CPU bound なので、ここがちょっと厄介になってくる。

Thread と ThreadPool

ファイル読み書きやネットワーク通信みたいな I/O 系の負荷は基本的には CPU 負荷とは独立してかかってくるので、CPU 処理の裏で非同期に I/O を走らせてやることで処理時間を隠蔽できる。ので、そこで Thread を使うことになる。
ただ今回のケースは「大量の画像の読み込み」というタスクなので、具体的に何個の Thread を立ててどう管理すればいいのかが悩ましい。コンテキストスイッチ避けに Pruducer/Consumer を自前で書くのも馬鹿らしいので、標準で用意されてる ThreadPool を使うことにした。

ThreadPool 自体は、あらかじめ決められた個数の Thread を用意しておいて、いいかんじに Thread を使いまわしながら大量のタスクを捌いてくれる仕組みのこと。Python には multiprocessing.pool.ThreadPool *3concurrent.futures.ThreadPoolExecutor の 2 種類があるんだけど、Python2 で使えるのは前者だけ。仕方ないので ThreadPool を使う。

PySide には QThreadPool というのがあるのだけど、C++ ならともかくそもそもが OS 非依存なインタプリタ言語の Python でそれを使う意義は特にない。*4

class ImageLoader(QObject):
    ...
    def __init__(self, directory):
        ...
        # self.__pool の生存期間を ImageLoader に一致させる
        self.__pool = multiprocessing.pool.ThreadPool(processes=20)
        ...

    def run(self):
        for path in glob.iglob(os.path.join(self.__directory, '*.png')):
            # self.task_func の実行をキューに積む
            task = self.__pool.apply_async(self.task_func, [path])
            self.__tasks[path] = task

    def task_func(self, path):
        ...  # サブスレッドで実行したい処理

apply_async() で ThreadPool 内部のキューにタスクを積んでおけば、スレッドが空いたタイミングで勝手に実行してくれる。終了検知のためには、本来であれば apply_async() に callback 引数を渡してやるのがいいんだろうけど、実装が煩雑になるので、今回は task_func() の最後で「すべてのタスクが消化されたかどうか?」を自前で判定してる。

ただし、このやりかたには注意点もある。apply_async の直後に yield されてサブスレッドで task_func の実行が開始されて、self.__task に task をストックする前に task_func が一瞬で完了してしまうと、実際にはまだタスクが積まれてるにも関わらず complete が emit されてしまう可能性がある。C++ とかで別コアにスレッドを立てるときなんかは、場合によってはこれに嵌る。ただ Python でこのタイミングで yield が走るかっていうと、正直それはちょっと考えてにくい。というか、手許で試した範囲では、そんなこと一度も起きなかった。まぁとはいえ、リスクとして認識しておく必要はありそう。

スレッド間の役割分担

一般的に、GUI のリソース生成と描画はメインスレッド *5 でしか行うことができないことが多い。
この点で、上に挙げた「ボトルネック」を整理すると、ざっくりこうなる。

ボトルネック サブスレッドで実行可能か?
(1) 画像ファイルの読み込み
(2) 読み込んだ画像の縮小
(3) GUI リソースの作成 x

というわけで方針としては、「読み込み」と「縮小」のオーバーヘッドを可能な限り隠蔽すべく、この2箇所をサブスレッドに分散する。

class ImageLoader(QObject):
    def __init__(self, directory):
        ...
        # 20スレッドで「読み込み」「縮小」を行う
        self.__pool = multiprocessing.pool.ThreadPool(processes=16)
        ...

    def run(self):
        for path in glob.iglob(os.path.join(self.__directory, '*.png')):
            task = self.__pool.apply_async(self.task_func, [path])
        ...

    def task_func(self, path):
        item = ImageItem(path)  # この中で「読み込み」+「縮小」
        ...

今回のテストでは 16 コアのマシンを使ったので、(1)+(2) を行うためのスレッドも 16 個用意することにした。これを何個にすべきかは実行環境や扱う画像の個数によるので一概には言えないけど、今回の場合はコア数と同じだけ用意するのが一番よかった。

参考までに、手許でスレッド数を変えながら計測した結果を貼っておく。

f:id:hal1932:20190824223310p:plain
スレッド数あたりの処理負荷

結果、わりといいかんじに分散できてる。おそらくだけど、QImage がちゃんと GIL を考慮した実装になってる *6 んだと思う。

f:id:hal1932:20190824223821p:plain
16スレッド動作時のCPU負荷

QImage と QPixmap

さて、PySide で画像を表示するといえば、まず最初に思いつくのが QPixmap だと思う。こいつは微妙に曲者で、少なくとも手許で試した限りでは Python27 だとメインスレッドでしか生成できず、Python37 だとサブスレッドでも生成できる。つまり、現時点での Maya ではこいつの生成をメインスレッドで行う必要がある。

一方で QImage というのもあって、QPixmap が「GUI 上に表示するためのリソース」であるのに対して、こちらは「ただの画像データ」でしかない。しかも I/O 用途に最適化されてる。そして、Python27 でも問題なくサブスレッドで生成できる。*7

Qt provides four classes for handling image data: QImage, QPixmap, QBitmap and QPicture. QImage is designed and optimized for I/O, and for direct pixel access and manipulation, while QPixmap is designed and optimized for showing images on screen.
Qt 5.18.0 ドキュメントより引用)

というわけで、今回の用途に対しては「サブスレッドで QImage の作成と縮小(=サムネイル化)まで行い、それをそのまま GUI に渡す」というのが最適化になる。たとえば ListView を使う場合、setViewMode(QListView.IconMode) した上で、Model.data() が role==Qt.DecorationRole のときに QImage を返してやればいい。これで問題なく動作する。*8

もしどうしても QPixmap が欲しければ、QImage → QPixmap の変換メソッドでも用意しておけばいい。メソッドの呼び出しスレッドが問題になる可能性もあるから、自動変換よりは明示的に変換させるほうが無難だろう。

class ImageItem(object):
    ...
    @property
    def image(self):
        # type: () -> QImage
        return self.__image

    @property
    def pixmap(self):
        # type: () -> QPixmap
        return self.__pixmap

    def __init__(self, path):
        ...
        self.__image = QImage(path).scaled(100, 100)
        self.__pixmap = None

    def convert_to_pixmap(self):
        self.__pixmap = QPixmap(self.__image)
        self.__image = None

雑多な話題

さて、パフォーマンスの向上に関してはここまで。ただ、何も考えず最適化だけすりゃいいってもんじゃないので、そのへんについても軽く触れておく。

いつ最適化するか?
早すぎる最適化は基本的に悪でしかない。パフォーマンスが問題になってから、パフォーマンスの改善について考えるようにする。
ただそうはいっても、いざ改善しようとなった時点で設計的に無理がでてきても仕方ない。なので、初期設計時点である程度のアタリは付けておきたい。たとえば今回の場合、画像の読み込みと表示で重くなるのは最初から目に見えてる。なので、「画像読み込み」や「サムネ作成」をあらかじめ独立させておくとか、ListWidget ではなく View/Model を意識しておくとか、そういった考え方が合理的。
もっとも、いつもこんなに分かりやすいわけではないし、経験と勘も必要だったりして難しい。もしアタリを付けるのが難しければ、あとで書き直す覚悟でざっくりした実装をスピード重視で進めてしまえばいいと思う。

最適化の前には必ず計測して仮定と目標を設定する
「(1) どこが遅いのか?」「(2) なぜ遅いのか?」「(3) どれくらい速くなればいいのか?」
この 3 つが見えない状況では、基本的には最適化を行うべきではない。分からないものを当てずっぽうに弄り回したって泥沼に嵌るだけ。どんなに最低の場合でも (1) と (3) だけは目処をつけてから作業を開始すること。*9

そもそも最適化が必要か?
速ければ速いほうがいいってのはまったくそのとおりなんだけど、そもそもそれ、「処理中はマウスカーソルを WaitCursor にしてボタンを押せなくする」だけで十分だったりしない?
たとえばツールの処理時間が 33ms から 16.ms になったところで特に意味は無い。1-2 秒くらいなら待たせても集中はそう簡単に切れたりしない。でも 3 秒超えたら厳しい。10秒なんてもってのほか。1 分?流石に論外でしょう。*10
じゃあたとえば 5 秒かかる処理。これを最適化すべきか? 1-2 秒にまで短くできる見込みがあるなら、あるいは十分な工数が取れるなら、是非ともやるべきだろう。問題は 3-4 秒にしかできない場合、仮にうまくいっても「遅い」ことには変わりない。随分乱暴な物言いだけど、それが許されるユースケースは結構たくさんあるんじゃないかな。
あとわりとよくあるのが、「GUI が固まってる 1 秒」よりも「GUI が動き続けてる 2 秒」のほうが短く感じることも多い。場合によっては、多少遅くなったとしても GUI の動的な更新を優先させたほうが良いケースも多々ある。

まぁとはいえパフォーマンスはパイプラインの基礎体力。できるだけ速い仕組みを用意したいものです。

*1:C++とかは書かない。Pythonだけ。

*2:たとえば「QListWidget ではなく QListView/QAbstractListModel を使う」みたいな。

*3:なぜか公式ドキュメントに辿り着けなかった。。multiprocessing.pool は本来マルチプロセスを扱うモジュールなんだけど、その亜種としてマルチスレッドも扱うことができる。

*4:ただ QThreadPool の場合はスレッドごとのスタックサイズなんかも指定できるので、そういうのやりたいときは QThreadPool を使えばいい。

*5:より正確にいうと、GUI コンテキストが生成されたスレッド。

*6:I/O や画像縮小処理の前にロックを手放してるとか。

*7:QPaintDevice を継承してるのがちょっと心配だけど、scaled とかも問題なくサブスレッドで動く。ドキュメントの文面から察するに GUI のリソースハンドルは抱えていなさそうだ。

*8:内部的には QPixmap が生成されてる可能性が高そうだけど、まさかピクセルデータのディープコピーなんてするまいし、どうせ誤差の範囲内だろう。

*9:本当に最後の最後になるフェーズでは、「1ミリ秒でも速く!」という要求が出てくることもなくはない。その場合は、(1)(2)(3) のすべてが見えなかったりもする。ツール開発ではあまり見たことないけど……。

*10:とはいえそんな処理が必要なケースは多々ある。単純な「最適化」だけじゃなく、如何にそれを「分割」「隠蔽」するかって視点も併せて考えたい。とはいえ本当にどうしようもないときは仕方ない……。