PyQtでお手軽GUI開発♪―――は可能だったか? 第13回 並列処理編

PyQtでお手軽GUI開発♪―――は可能だったか? 第13回 並列処理編

 前回、モザイクは外せましたが計算速度に少々難がありました。しかも今回の場合はNumbaなどを使っても高速化できません。

 しかしまだ最後の手段、並列処理というものが残されていました。

Pythonでの並列処理

 仕事を早く終わらせたい場合、大勢に協力してもらうというのは多くの場合に非常に優れた方法です。最近のPCのCPUはマルチコアが普通です。そして特に断りなく書いたプログラムは、普通はそのうちの一つしか使いません。従って計算に他のコアも利用できるようになれば、その分実行速度は上昇するわけです。

 もちろん問題の中には並列処理が行えないような物もありますが、マンデルブロ集合の場合は各ピクセルがまったく独立に計算されるので、並列処理には非常に適している種類だと言えます。


 ここで問題になるのが、一般的に並列処理というのはプログラミングがかなり大変だということでしょう。

 しかしPythonにはthreadingというモジュールがあって、その中のThreadオブジェクトを使えば簡単そうです。

 使い方はThreadクラスのサブクラスを作って、runメソッドをオーバーライドして、インスタンスをstartしてやればいいだけです。

 といいつつ、いきなりマルチスレッドを試したりはしませんでした。というのもそのThreadオブジェクトの説明の最後に……

CPython は Global Interpreter Lock のため、ある時点で Python コードを実行できるスレッドは1つに限られます。アプリケーションにマルチコアマシンの計算能力をより良く利用させたい場合は、 multiprocessing モジュールなどの利用をお勧めします。

 ―――などという一文があったためです。


 並列処理がめんどくさいのは、並列で動作しているタスク間の同期が大変だからです。一つの問題をみんなで処理する以上、何らかの共通作業領域が必要なのは明白ですが、そこでデータ読み書きの競合が起こったら大変だし、作業順序に依存関係があることもよくあります。

 そこでロックだのセマフォだのといった仕組みであれこれ始めるわけですが―――要するにそんな作業管理の方が大変になってしまって、結局一人でやってた方がマシということになってしまいがちなわけです。

 そしてあらゆる一般的な状況を想定しなければならないインタープリタで安全にそれを実現するのはとても大変そうだということは想像がつきます(頑張ってやってみたけれど普通のときのパフォーマンスが落ちてしまったそうです)

 そこでPythonでは清々しく、一つのインタープリタ上では原則的にスレッドは一つしか動かないとしてしまったのでした。

 しかしそうなるとスレッド化は今回のような問題でパフォーマンスを上げるための手段にはなり得ません。

 そしてそんな場合はもう開き直ってプロセスベースで並列処理してください―――要するにOS上でインタープリタを複数起動してしまえということです。そうなれば何かあっても責任はみんなOSのせいにできるわけです🖤


プロセスプール

 とうわけでmultiprocessingというモジュールを使うことになるわけですが―――しかしマルチスレッドというのでも一度やったら相当に面倒くさかった覚えがあるのですが、マルチプロセスとなればもっと面倒くさいんじゃないでしょうか?

 マルチスレッドの場合は同じプロセス内なので同じメモリ空間をアクセスできますが、プロセスが異なってしまうとそういうわけにはいかず、共有メモリとかパイプとかキューなどといった相応の仕組みが必要です。そのあたりをしくじると嫌~なタイミングでたま~に起こるバグやデッドロックを食らうわけで―――実際最初にプロセスの作り方などを書いてる節は読んでも結構目が滑ってしまいます。


 しかし並列処理といっても色々あって、異質な処理を並行しながら連携したい場合もあれば、同質の処理を高速化のために並行したい場合もあります。マンデルブロ集合の計算の場合は典型的後者なのですが―――そこでよく見てみるとmultiprocessing モジュールの説明の後ろの方にプロセスプールなる物がありました。

 どうもある関数を並列実行したい場合には、それを使うとえらく簡単そうです。どう使うかというと……

  1. Poolオブジェクトを作ります。その際のパラメータには普通はプロセス数を指定するだけでOKです。しかもコンテキストマネージャ付き―――要するにwith文が使えるので、後始末も気にしなくて構いません♡
  2. できたインスタンスのmapメソッドに、並列実行したい関数とその引数リスト(やイテレータ)を与えます。これは組み込み関数のmapと似たようなものですが、引数のiterableが一つしか取れないという制限があります。そのため複数のパラメータを取る関数では使えないように思えますが、これは単に引数をタプルにパックしてしまえばいいだけです。

 ―――以上で終了です。

 Pythonのmap関数というものの概念さえ分かっていれば、まさに簡単明瞭といっていいでしょう。


 そこでマンデルブロ集合の計算は現在ピクセル単位で関数を呼び出していますが、今回は行単位に計算して結果を返す関数に変更します。二重ループの内側を組み込むだけなので大した変更ではありません。

 そうやって改造したのが以下のソースです。

def multifunc(args):
    """1行単位で結果を計算する"""

    a, b, b0, pw, iw, ih, cmax, dig, C4 = args  #引数タプルを展開する

    r = [0]*iw                                  #内側ループ
    for i in range(iw):
        a0 = a + pw*i

        x0, y0 = a0, b0                         #以下がマンデルブロ計算関数
        xx0 = (a0*a0) >> dig
        yy0 = (b0*b0) >> dig
        for n in range(cmax):
            x1 = xx0 - yy0 + a0
            y1 = (((x0+x0)*y0) >> dig) + b0
            xx0 = (x1*x1) >> dig
            yy0 = (y1*y1) >> dig
            if xx0 + yy0 > C4:
                break
            x0 , y0 = x1, y1
        r[i] = n

    return r                                    #1行分の結果を返す


def mandel_i_p(data, aF, bF , pwF, zoom, iw, ih, cmax):
    """固定小数点化マンデルブロ計算ルーチン(並列処理)

    data: 結果保存用のリスト
    aF,bF: 計算開始位置の座標(fdecタプル)
    pwF : ピクセルのサイズ(fdecタプル)
    zoom: 拡大率(この描画における小数点桁数の基礎となる)
    iw,ih: 計算する領域の幅と高さ(pixel)
    cmax: ループの最大回数
    calc: 計算関数(a0, b0. cmax, dig, C4)
    """
    processcount = 3                            #core i3では結局これが最適だった

    dig = zoom + 12                             #いろいろな初期設定
    C4 = fdec.setint(4, dig)[0]
    a = fdec.setdigit(aF, dig)[0]
    b = fdec.setdigit(bF, dig)[0]
    pw = fdec.setdigit(pwF, dig)[0]

    params = [None]*ih                          #各行のパラメータをタプル化してリストに入れる
    for j in range(ih):
        b0 = b + pw*(ih-j)
        params[j] = (a, b, b0, pw, iw, ih, cmax, dig, C4)

    with Pool(processcount) as p:               #並列処理の実行
        data = p.map(multifunc, params)

 並列処理本体は最後のたった2行で、こんなんでいいのかと心配になりますが……


いきなりできちゃいました!

 Numbaのときにはここではまだ力の使い方に慣れていなかったのですが、今回はいきなり結果が表示されてしまってびっくりです!

 本当ならここでもっと難航する予定だったんですが―――まずはウンともスンとも言わず、動いてみれば結果はぐしゃぐしゃで、途中で変なエラーも出てきたりするんで、あちこちググって疲れて結局ふて寝して、というところまでがテンプレのはずだったのですが……


 ただ、ささやかな問題として、map関数の実行中にウインドウが応答なしになってしまうのです。


ウインドウ、応答せず!

 どういうウインドウシステムの場合でも、各ウインドウの中ではイベントループというものが回っていて、そこにイベントが来なくなると何もできなくなってしまいます。タイトルバーに(応答なし)とか表示されたり「このアプリケーションは応答していません。強制終了しますか?」云々といったダイアログが出てくるのはそういう場合です。

 そしてmap関数は実行に入ったら結果が出るまで処理を戻さないため、その間ウインドウが固まってしまうのです。

 それでも正常に計算はできているので無視しておけばいいかもしれませんが、その間はウインドウを動したくても動かせないというのは正直困ります。


 そういう場合、長い処理をしている関数の中から時々メインウインドウにプロセスメッセージを送ってやればいいのですが、Qtの場合はQApplicationの継承元であるQCoreApplicationprocessEventsという関数でそれが行えます。

 QApplicationは以前のサンプルではappという名の変数として作っているので、メインモジュールではそのまま参照できます。


 しかしmap関数中の別プロセスからそんな物を呼ぶのは(試してませんが多分)無謀そうだし、世の中にはプロセス間通信というものもありますが、こんなことのために使うのも話が無駄に大げさです。map_async というのも今ひとつよく分からないし―――などと思っていたら、その次にあったimapという関数が使えました。


 このimapとはmapの遅延評価版とありますが、要するにmapの場合は計算結果を最後にまとめて返してくれるのに対して、imapでは結果がイテレータになっており、それをfor文でぶん回していれば、結果が出次第、順番に返してくれるのです。

 従ってそのfor文中でprocessEventsを呼んだり、プログレスバーを出したりできるわけです。

 そしてこれまでのサンプルには見かけが複雑になるんで書いていませんでしたが、別にマルチプロセスでなくとも同じ問題は起こっていたので、既にそういう仕組みは作っていました。


 そんな感じで実際に動かしたのが以下のサンプルです。

def mandel_i_p(self, aF, bF , pwF, zoom, iw, ih, cmax, app):
    """16Lリストの自前固定小数点化(並列処理)

    self: MyForm
    aF,bF: 計算開始位置の座標(fdecタプル)
    pwF : ピクセルのサイズ(fdecタプル)
    zoom: 拡大率(この描画における小数点桁数の基礎となる)
    iw,ih: 計算する領域の幅と高さ(pixel)
    cmax: ループの最大回数
    app: QApplication
    """
    processcount = 3                                #core i3では結局これが最適

    t = time.perf_counter()                         #ここからプログレスバーなどの前処理
    self.ui.progressBar.setMaximum(ih)
    self.ui.progressBar.setValue(0)
    self.ui.progressBar.show()
    self.esc = False
    self.ui.statusbar.showMessage('Escで中断するよっ!')
    self.ui.statusbar.update()

    dig = zoom + 12                                 #いろいろな初期設定
    C4 = fdec.setint(4, dig)[0]
    a = fdec.setdigit(aF, dig)[0]
    b = fdec.setdigit(bF, dig)[0]
    pw = fdec.setdigit(pwF, dig)[0]

    params = [None]*ih                              #各行のパラメータを作成してタプル化する
    for j in range(ih):
        b0 = b + pw*(ih-j)
        params[j] = (a, b, b0, pw, iw, ih, cmax, dig, C4)

    with Pool(processcount) as p:                   #並列処理の実行
        it = p.imap(multifunc, params, 4)           #imapからはイテレータが戻ってくる
        for n, j in enumerate(it):                  #インデックスも必要なのでenumrateを使う
            self.mandeldata[n]  = j

            if time.perf_counter() - t > 0.25:      #0.25秒経過していたら
                self.ui.progressBar.setValue(n)     #プログレスバーを進める
                app.processEvents()                 #ウインドウのメッセージ処理
                if self.esc == True:                #ESCが押されていたら終了する
                    break
                t = time.perf_counter()

    self.ui.progressBar.hide()

◆ESCで停止する処理

 ESCで停止する処理に関しては、メインウインドウのkeyPressEventで以下のように値を設定しています。

    def keyPressEvent(self, e):
        """遅い描画中にESCで抜けられるようにする"""

        if e.key() == Qt.Key_Escape:
            self.esc = True                         #キーが押されたら値を設定する

Intelの罠

 というわけでこうして関数ができたので、さっそく実行時間を計ってみましょう。


 i_mandel1というのが並列処理化していないもので、i_mandel2というのが上述のサンプルでprocesscountのパラメータを1~4と変えたものですが……

#ループ500回

   関数      プロセス     実行時間      速度比
-----------------------------------------------
 i_mandel1       -        8,767 ms       1.00
 i_mandel2       1        9,611 ms       0.91
 i_mandel2       2        5,548 ms       1.58
 i_mandel2       3        5,732 ms       1.53
 i_mandel2       4        6,220 ms       1.41
-----------------------------------------------

 確かにプロセス数が増えれば明らかに速度が増していますが―――このくらいだと2プロセスが一番速いという結果になっています。

 しかしマルチプロセスの場合それこそインタープリタを複数起動するわけなので、開始のオーバーヘッドは相当かかっていそうです。このくらいだと差はあまり出ないかもしれません。そこでループ回数を増やしてテストしてみると……

#ループ2000回

   関数      プロセス     実行時間      速度比
-----------------------------------------------
 i_mandel1       -       34,040 ms       1.00
 i_mandel2       1       35,069 ms       0.97
 i_mandel2       2       20,072 ms       1.70
 i_mandel2       3       19,195 ms       1.77
 i_mandel2       4       20,333 ms       1.67
-----------------------------------------------

 何だかプロセス数が3のときに最速になっていますが、正直プロセス数2や4のときと大差ありません。CPUの使用率はプロセス数に比例して50%、75%、100%と跳ね上がっていくんですが、いずれもi_mandel1の1.8倍弱にしかなっていません⁈


 えーっとこれは―――って、もうお分かりですね。以前に書いた私の実行環境ですが、使っているマシンのCPUは Intel Core i3-4000M 2.40GHz という奴です。そのスペック表には2コア4スレッドとあって、要するに物理的スレッドは4つ動かせるがコアそのものは2つしかないという代物で、その名もハイパースレッディングなどとカッコはいいんですが……

ハイパースレッディングとは、従来CPUのコア一つに一つしか搭載していなかったコードを実行する装置を複数搭載してコードの処理能力を向上するものである。これにより、ハイパースレッディングを備えたCPUではホストOSから実際搭載しているコア数より多くのコアを搭載しているよう「論理的に」見えることとなり、実コア数より多くのスレッドやプロセスをOSが同時に実行できるようになる。(ウィキペディア)

 ―――とのことで、確かにタスクバーを見るとCPUは4つあるんですが、完全独立のコアではないからいろいろ制限はあるだろうと予想はしておりました。だからもちろん4倍なんてのは無理だろうけど、3倍くらいは行くんじゃないかな~~っとそこはかとなく期待してたんですが―――ウィキペディアにはその後……

従ってハイパースレッディングで効率が良いのは、比較的小さなサイズの整数処理のコードと、データサイズが小さい、もしくはデータサイズが大きくても配列が規則的な浮動小数処理やマルチメディア処理の繰り返しが並行して行われている場合である。

 ―――などという続きがあったりして……


えーっと、今回は思いっきり整数演算しかしてません!

 すなわち、これじゃどうあがいても2倍までしか速度向上は見込めないということです。

 しかも上記の結果を見るとマルチプロセス化によって一律に1秒くらいの遅延があるようなので、それを勘定すると1プロセスで34秒、最速の3プロセスが実質18秒とすれば既に1.9倍弱にはなっていて、チャンクサイズの調整やimap_unorderedとかを使ったりすることでもう少し何とかなるかもしれませんが、それでも良くてあと5%程度が限界なわけです。


 ちなみに3コアで最速になるというのは、他のプロセス用に1スレッドくらい空けておいた方が全体のパフォーマンスが上がるということでしょうか。

 まあ、それはともかく―――ぐぬぬ。見通しがちょっと甘かったってことですよ!

最後の希望

 ともかくインテルのエセマルチコアに少し心を折られたわけですが、でもまだ希望の灯が消えてしまったわけではありません。

 なぜなら、少なくともこの方法が有効であることは証明できました。私のマシンがしょぼかったからこの程度の結果なわけで、4コアとか8コアのマシンなら実コア数に比例して高速化は見込めるわけです。最近なら4コアでクロックが3.4GHzとかいったクラスのデスクトップPCなら5~6万円であります。すなわち―――いや、さすがにこのためだけに新しいマシンを買う気にはなれませんねw

 そうではなくて私の場合、使えるPCがあと2台残されていたのです! その意味することとはすなわち―――リモートプロセッシングという最後の大技です!


 というわけで次回は本編最終回、無量大数の彼方に!編をお送りします。

2017-05-13