Thor's Columns
PyQtでお手軽GUI開発♪―――は可能だったか? 第15回 無量大数の彼方に!編

PyQtでお手軽GUI開発♪―――は可能だったか? 第15回 無量大数の彼方に!編

 さて、ついにリモートマンデルブロ並列システムの作成に入ることにします。


ワーカーリストの作成

 まずはワーカーリスト(=外部からのconnectionのリスト)を作るスレッドです。

 やることは下記のように超シンプルなので、チャットクライアントのときのように、スレッドの機能は単なる関数として書いて、Threadのコンストラクタで呼び出す方式にします。

def getcalcprocess(self):
    """外部からのコネクションリストを作成する"""

    address = ('192.168.10.8', 6000)
    with Listener(address, authkey=b'secret password') as listener:
        print('start mandel server')
        while True:
            #acceptが来たときしかループは回らない
            conn = listener.accept()
            print('connection from', listener.last_accepted, conn.fileno())
            self.workers.append(conn)

 そしてMyFormの__init__の中で以下のように初期化します。

        self.living = True
        self.workers = []
        self.mandelserver = Thread(
                target=getcalcprocess, args=(self,), daemon=True)
        self.mandelserver.start()

マンデルブロサーバー

 マンデルブロ集合の計算を行うサーバーは以下のような物です。


 ただしサーバーではもう計算そのものはまったく行わず、パラメータをリスト化してワーカーに送り、結果を集計しているだけです。

 以下の例では各ワーカーに対して10行分のデータを計算して返してもらうようにしています。

 送りあうデータは(':コマンド', データ)という形式のタプルで定義しています。

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

    self: MyForm
    aF,bF: 計算開始位置の座標(fdecタプル)
    pwF : ピクセルのサイズ(fdecタプル)
    zoom: 拡大率(この描画における小数点桁数の基礎となる)
    iw,ih: 計算する領域の幅と高さ(pixel)
    cmax: ループの最大回数
    calc: 計算関数(a0, b0. cmax, dig, C4)
    app: QApplication
    """
    if len(self.workers) == 0:
        print('計算プロセスがいません')
        return

    dig = zoom + 12
    C4 = fdec.setint(4, dig)[0]

    #プログレスバー処理
    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()
    app.processEvents()

    #基本的なパラメータを指定桁数で整数化
    a = fdec.setdigit(aF, dig)[0]
    b = fdec.setdigit(bF, dig)[0]
    pw = fdec.setdigit(pwF, dig)[0]

    #ls行ずつまとめた計算パラメータを作る
    ls = 10                                         #まとめて処理する行数
    params = []
    p = []
    for j in range(ih):
        b0 = b + pw*(ih-j)
        p.append((a, b, b0, pw, iw, ih, cmax, dig, C4, j))
        if len(p) >= ls:
            params.append(p)
            p = []
    if p != []:                                     #端数が出ていた場合
        params.append(p)

    #ワーカーに初期化命令を送る
    for rp in self.workers:
        rp.send((':init', ))

    #送った分が戻ってくるまで回してないといけない
    rest = len(params)
    while True:
        for rp in wait(self.workers):               #結果はリストなのでforでも回す

            msg = rp.recv()
            print('from {} - command: {}'.format(rp.fileno(), msg[0]))

            #計算結果が来た場合
            if msg[0] == ':data':
                for l in msg[1]:
                    self.mandeldata[l[0]] = l[1]    #結果には行番号も含まれている
                rest -= 1

            #パラメータ要求が来た場合
            elif msg[0] == ':request':
                if params:
                    rp.send((':param', params.pop(0)))

            #プログレスバー処理
            if time.perf_counter() - t > 0.25:
                self.ui.progressBar.setValue(ih - rest*ls)
                app.processEvents()
                if self.esc == True:                #ESC処理では
                    self.resetClient()              #ゴミが残るのでいったん切ってつなぎ直す
                    rest = 0
                t = time.perf_counter()

            #結果を全部受け取ったら終了
            if rest == 0:
                self.ui.progressBar.hide()
                return

    def resetClient(self):
        """リモートクライアントをリセットする

        ※ディスコネクトしたら5秒後くらいにつなぎ直してくる
        """
        for rp in self.workers:
            rp.send((':disconnect', ))
        time.sleep(0.5)
        self.workers.clear()

 今までのものに継ぎ足し継ぎ足しで作ってるんで結構長い関数になっちゃってますが、まあ各ブロックの機能は難しくないと思います。


マンデルブロクライアント

 マンデルブロ集合の計算に関しては、全てクライアントが行います。

 クライアントはサーバーからパラメータリストを取得し、結果を計算して送り返します。

 パラメータリストは例えば10行単位とかでやってきますが、今回の場合マシンが異なるので各プロセスの計算時間はまちまちで戻ってくる順序も不同です。そのため結果には行番号も含まれています。

# -*- coding: utf-8 -*-
"""
Created on Mon Mar 20 10:37:12 2017
マンデルブロ計算リモートクライアントプロセス

@author: Lum
"""
import sys
import time
from multiprocessing import Pool
from multiprocessing.connection import Client

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

    1行の結果を納めたリストを返す
    """
    a, b, b0, pw, iw, ih, cmax, dig, C4, j = 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 j, r     #行番号と結果のタプルを返す(どのプロセスが早く終わるか分からないので)

def culc(params, p, conn):
    """マンデルブロ計算を行う"""

    t = time.perf_counter()
    res = []
    for r in p.imap(multifunc,params):
        res.append(r)
        if time.perf_counter() - t > 0.5:
            conn.send((':bleath',))     #長時間止まるので時々サーバーに息継ぎを送る
            t = time.perf_counter()
    return res

def main():
    """サーバーに接続する"""

    endcode = False
    address = ('192.168.10.8', 6000)
    with Client(address, authkey=b'secret password') as conn:
        print('接続開始')

        processcount = 3
        with Pool(processcount) as p:   #poolを作ったり消したりしてると遅いので最初に作る
            while True:
                if conn.poll(0.001):
                    msg = conn.recv()

                    if msg[0] == ':param':
                        r = culc(msg[1], p, conn)       #来たパラメータを振り分けて計算
                        conn.send((':data', r))         #データを送り返す
                        conn.send((':request', ))       #続きのリクエスト

                    elif msg[0] == ':init':             #初期化の場合
                        conn.send((':request', ))

                    elif msg[0] == ':disconnect':       #終了の場合
                        break

                    elif msg[0] == ':terminate':        #クライアントのリモート終了の場合
                        endcode = True
                        break
            conn.close()

    print('disconnect')
    return endcode

if __name__ == '__main__':

    while True:
        try:
            if main():
                break
        except ConnectionError:
            pass
        except:
            raise
        time.sleep(5)                                   #接続が切れたら5秒待ってつなぎ直しにいく
    print('mandelclient 終了')
    sys.exit()

 前回のマルチプロセス版をベースに作成しているので2段階構成で複雑になっていますが、シングルプロセスにして必要な数をOS上で立ち上げるというのもありだったかもしれません。そうしたらもっとシンプルな構成になったでしょう。

 あと、エラー処理とかも適当ですが、気にしないようにしましょうw


努力は報われたか?

 それは何はともあれ速度を測ってみることにします。


 まずはいつも通りのパラメータでループ回数2000回でやってみます。

 使ったサブマシンはIntel Core i5-2410M 2.30GHzの搭載されたノートパソコンで、このCPUも2コア4スレッドです。

 すると……

#ループ2000回

   関数      実行時間    速度比               補足
-------------------------------------------------------------------
 i_mandel1   34,239 ms    1.00    シングルプロセス(メイン)
 i_mandel2   19,391 ms    1.77    マルチプロセス(メイン)
 i_mandel2   21,080 ms    1.62    マルチプロセス(サブ)
 i_mandel3   19,623 ms    1.74    リモートプロセス(メインのみ)
 i_mandel3   20,869 ms    1.64    リモートプロセス(サブのみ)
 i_mandel3   10,756 ms    3.18    リモートプロセス(メイン+サブ)
-------------------------------------------------------------------

 サブマシンはCore i5ですが少し古いので、メインマシン(Intel Core i3-4000M 2.40GHz)とほぼ互角といっていいでしょう。

 マルチプロセスとリモートプロセスではほぼ時間の差はありません。サブマシンの方はむしろリモートプロセスの方がやや速くなっています。これはリモートクライアントの場合Poolオブジェクトを最初に一回作ってそれをずっと使っているので、それで転送時間のロスなどが相殺されているのかもしれません。


 そしてそんな2台を並列したら、シングルプロセスのときの3.2倍弱となっています。


 何だか思ったほどでもないような気がしますが―――しかし計算時間が短いといろんなオーバーヘッドが効いてきそうなので、ループ回数を増やしてやってみます。

#ループ4000回

   関数      実行時間    速度比               補足
-------------------------------------------------------------------
 i_mandel1   67,888 ms    1.00    シングルプロセス(メイン)
 i_mandel2   37,823 ms    1.79    マルチプロセス(メイン)
 i_mandel3   20,956 ms    3.24    リモートプロセス(メイン+サブ)
-------------------------------------------------------------------

 さっきより速度比が上がってきました。ではもう少し回数を増やしてみると……

#ループ8000回

   関数      実行時間    速度比               補足
-------------------------------------------------------------------
 i_mandel1   135,349 ms   1.00    シングルプロセス(メイン)
 i_mandel2    74,246 ms   1.82    マルチプロセス(メイン)
 i_mandel3    38,542 ms   3.51    リモートプロセス(メイン+サブ)
-------------------------------------------------------------------

 時間が長ければ速度比もUPしています!

 マルチプロセスのときの速度比が1.8倍くらいなので、1.8×2 = 3.6倍がリモート並列処理で出せる限界と考えれば、まあまあな結果と言えるでしょうか。


 それでは続いてもう一台追加して3台でやってみましょう。

 今度のマシンは一応デスクトップですが、搭載されているのはAMD E2-1800 APU 1.70GHzというショボい奴で、動画視聴専用に買った物だったんですが、まずループ2000回でやってみると……

描画時間: 9316.7 ms

 何だかほとんど変わってませんが……

 ループ4000回だと?

描画時間: 23480.7 ms

 えっと―――2台のときの20,956 msより遅くなってます!

 ls値(1回にまとめて計算する行数)を調整したら一度は17827.3 msなんて値も出ましたが、基本的にばらついて一定しません。

 そこで追加したマシン単体で速度を測ってみると、ループ500回で……

描画時間: 18831.2 ms

 メインマシンでは5,732 msぐらいですから、3倍以上遅いです。


 要するに速度が揃っていないマシンで並列処理する場合は、個々の速度に合わせて送るデータ量を変えるようにしないとダメなのだということのようです。

 しかし、今のルーチンでは一律の分割にしか対応していないので、プロセスごとに行分割数を変えられるように修正しなければなりませんが―――そもそも3倍も遅ければ頑張ってそんな調整をしてもよくて30%くらいしか速度上昇は期待できないわけで、さすがにそろそろ疲れてきたのでやめることにします。


無量大数の彼方

 というわけでとりあえずこのくらいが我が家できる限界のようです。


 結局シングルプロセスの場合に比べて3.5倍程度だったということですが―――Numbaのときに170倍なんて数字を見たあとなので大したことのない結果のように見えますが、1時間かかっていた描画が20分以下になるわけですから、一般的には相当の成果というべきでしょう。


 それにちょっとした小技を駆使すれば、これでもマンデル世界周遊は十分堪能できます。

 というのは、表示していた画像の1辺の長さを1/4にすれば面積は1/16、すなわち16倍の速度で描画できます。更にそれが3.5倍ということは、そんなサムネイル画像なら56倍の速度で描けるということです!

 また周遊していて「あ、これは取っておきたいな!」という場所はそうそうはないわけで、ならば普段はサムネイルで見ていて、ここぞというときだけ拡大表示をすればいいわけです。


 ―――というわけで以下の画像はそんな風にして見つけた、我が家の最小マンデル画像です。



 画像のパラメータは以下のようになります。


a=-121196956243322476017002630862464943201162004021012714960012871046886647962260363002×2-275

b=-5977620834308090852731453170213033535974435018005854755714623171861131107732042×2-275

pw=2-275

cmax=100000


 10進小数で表してみれば……


a=-1.99637859817836551138524329341609141317919645181161071798016214240476678608697459760874

b=-0.00009846447197633543694465711869726262041015803722893731276473986449846432076683881463

pw=0.00000000000000000000000000000000000000000000000000000000000000000000000000000000001647


 ちなみに前述の通り、数字の桁数が増えればその2乗に比例して計算時間がかかるようになるので、精細になればなるほどどんどん時間がかかるようになります。また細かければ細かいほどループ回数を増やす必要も出てきます。

 そんなわけで上の画像の場合、125*125のサムネイルでも2分以上かかってしまい、実際に描画するのには1時間近くが必要になります。


 下は低倍率で見たマンデルブロ集合の全景ですが、上のちびマンデルと見た目の大きさは大体同じです。下の1ピクセルサイズが2-6なので、上記の画像は左に伸びた尖った角の先端部付近を2269倍拡大しないと見えてこないことになりますが……


a=-182×2-6, b=-150×2-6, pw=2-6

 これがどんな数かというとPythonなら簡単に計算できて、普通に書き表せば……


 2269 = 948568795032094272909893509191171341133987714380927500611236528192824358010355712


 ―――指数表記で表せば約 9.485687950320943×1080となりますが、ウィキペディアによれば観測可能な宇宙の大きさは直径930億光年 = 8.798×1026m なんだそうで―――これでは全然足りません。

 物理的な意味を持ちうる最小の距離をプランク長といって、その長さが1.616×10-35m なのでこれを単位に表せば、宇宙の大きさは約5.444×1061(= 約54那由他)プランク長になりますが、これに比べても1.744×1019倍 = 1700京倍ほど大きい値です。


 那由他とか京なんて単位が出てきたんでついでに日本語で表せる一番大きな数といえば9999無量大数9999不可思議9999那由他9999阿僧祇9999恒河沙9999極9999載9999正9999澗9999溝9999穣9999𥝱9999垓9999京9999兆9999億9999万9999 = 1072-1 ですが(これにも色々流儀があるようですが)それよりも9億4千倍ほど大きい値です。

 もう本当にすごくすご~~~~~~~く大きな数です。


 とはいっても大きさ比べを始めたらWeb上には10100や10200にチャレンジしてる人もいたりするので、それこそ四天王どころかザコもザコでしょう。

 しかしPythonを使って普通のノートパソコン上で試行錯誤しつつ2週間程度でちょこちょことやった結果とすれば、わりと頑張ってるのではないでしょうか?

 まあともかく何よりも楽しかったんでそれでよしにしましょう。


 というわけでPyQtやマンデルブロ集合へのチャレンジはこのへんで終わりにしたいと思います。

 次回は最後のまとめとして、そもそもの表題が「PyQtでお手軽GUI開発♪―――は可能だったか?」などという疑問文になっていたわけですが、それに対する自分なりの答えを出してみたいと思います。

2017-05-18