[Python]分散処理フレームワークRayでmutexらしきものを作る。

背景

私が調べた限り、Ray v2.4.0時点で、多数のノードにまたがって動作するmutexは実装されていない。

一応、Actorが共有リソースと排他処理を実現しているので、これで十分な場合もあるだろう。しかしActorはあるノードの中で1個のプロセスとして動作するらしく1、どのノードのどのプロセスからActorのメソッドを呼び出しても特定のプロセス内で呼び出されてしまう。私はプロセスごとにローカルかつ排他的に呼び出したい処理があったので、Actorの持つメソッドという方法では実現できなかった。
単一のノード内で動かす場合はAsyncIOなどが動作するという話も見かけたのだが、私の場合は残念ながら分散処理をさせたいのでこれも不適当だ。

というわけで、lock、unlockが可能なmutexを作成することにした。幸いなことに、Actorは必ず排他的に処理を実行できるので、これを使えばmutexのような動作をさせることはできる。

動作確認はWindows 10のPC2台で行っている。RayのWindows版は現状ベータ版相当であることは留意されたい。

実装

0.1秒に一度mutexのlock状態を確認するだけの単純な仕組みである。いわゆるビジーウェイトによる極めていい加減な実装であるが、私はRayの中身についてろくに理解できていないので、これが今の私にできうる最大限であるし、私の用途ではこの程度の雑な実装でも特に問題にならないだろう。
リモート関数taskのうち前半のtime.sleep(2)は並列に実行されるが、その後のlock-unlock内に括られているtime.sleep(1)は排他処理となっている。

import ray
import time
import os

@ray.remote(num_cpus=0)
def task(num, mutex):
    time.sleep(2)
    print(f"task {num}")
    lock(mutex)
    time.sleep(1)
    unlock(mutex)
    print(f"done {num}.")
    return num

@ray.remote
class RayMutex:
    def __init__(self):
        self.lock = False
    def is_locked(self):
        return self.lock
    def set_unlocked(self):
        self.lock = False
    def set_locked(self):
        if self.lock: return True
        self.lock = True
        return False

def unlock(mutex):
    mutex.set_unlocked.remote()

def lock(mutex, interval=0.1):
    waiting = True
    while waiting:
        while ray.get(mutex.is_locked.remote()):
            time.sleep(interval)
        waiting = ray.get(mutex.set_locked.remote())

if __name__ == "__main__":
    os.environ["RAY_DEDUP_LOGS"] = "0"
    ray.init(address="auto")
    mutex = RayMutex.remote()
    futures = [ task.remote(i, mutex) for i in range(0, 10) ]
    res = [ ray.get(f) for f in futures ]

  1. 例えば、Actorが(ip=192.168.0.10, pid=12345)に割り当てられたとする。このとき、別のノードのタスク(ip=192.168.0.20, pid=67890)からActorのメソッドを呼び出したとしても、そのメソッドは(ip=192.168.0.10, pid=12345)の中で実行される。これは私の望む動作ではなかった。

[SlackLogViewer]Slack過去ログ閲覧ツール更新(7)。

SlackLogViewerの更新情報である。GitHubリポジトリに来ていた多数の不具合、機能要求などを一掃すべく、色々と更新した。とはいえ、表面的な変更点は引用文、画像の表示くらいである。

SlackLogViewerの説明はこちらへ
Windows、macOS版のダウンロード先はこちらへ

Qt6への移行(Apple Siliconへの対応)

GitHubに寄せられたIssue #10によって知ったのだが、MacのうちApple Silicon搭載機ではSlackLogViewerが正常に動作しないことがあったらしい。ver-1.2.Alha-0以前はQt5を採用していたのだが、Qt5はApple Siliconにネイティブには対応していなかったため、Rosettaを介したx86-64用バイナリの実行時にエラーを起こしたのだと思われる。動作したという報告もあるので、おそらく環境によってまちまちなのだろう。

この際なので、Qt6に対応させた。Qt6はApple Siliconへ対応しているらしいので、Rosettaに頼ることなく動作するはず。ただこれも私の方では動作確認できないし、そもそも本当にarm64用にビルドできているのかどうかも謎なので、Apple Siliconユーザーの報告待ちである。

一応Qt5でもビルド可能にはなっている。CMakeに対して-DQT_MAJOR_VERSION=5というオプションを与えれば、Qt5が使用される。

日付の区切り線表示

日付の区切り線、と表現して通じるのだろうか。Slackのメッセージ欄は日付ごとに区切られて表示されるが、あの区切りのようなものをSlackLogViewerでも表示するようにした。特に実用上の意味はないが、会話の切れ目が多少見やすくなるかもしれない。

引用URLの中身を表示

メッセージ中に何らかのURLが添付されたときに、Slack側だとその先の大まかな内容が表示される。これをSlackLogViewerでもそれっぽく表示できるようにした。ただし表示方法は厳密には一致していないと思う。こちらのワークスペースでは引用付きのメッセージなどとうの昔に流れてしまったので、参考にできるものがほとんどなく、きちんと一致させるのは困難だ。

細かなバグ修正

Macではダークモード時に文字が白色で表示され読めなくなってしまうらしいので、文字色を黒に固定した。申し訳ないがSlackLogViewerをダークモードに対応させる気はない。そんな面倒なことやってられるか。

一部ファイルパスの区切り文字がバックスラッシュになっていたので、これを通常のスラッシュに直した。非Windows環境でzipファイルでなくフォルダを開こうとするとクラッシュするようだ。

Windows環境変数%LocalAppData%に非ascii文字が含まれている場合1にキャッシュフォルダを開けない不具合を修正した。Windowsは未だにOS側でローカルな文字コードを用いているので、Qtで扱う場合はいちいちUTF-8に変換しなければならないのだが、キャッシュフォルダの部分でそれを忘れていた(というより、自動で変換してくれていると思っていた)。ただ私の環境ではちょっと動作確認できないので、もし解決していないようであれば報告してほしい。

Parallel STL Algorithmを使用するかどうかを、コンパイラによって判断するように変更した。Parallel STL AlgorithmはMSVCはとうの昔に実装済みだが、GCCはTBBを利用することで一応実装しているもののGCC-10以前だと互換性の問題があり、Clangではあろうことか未実装である。SlackLogViewer制作当初はWindowsのみを対象としていたので、GCCやClangの中途半端な実装状況を知らず、コンパイラへの対応状況がちぐはぐだった。
実のところParallel STL Algorithmが利用されているのは検索したメッセージを日時順にソートするときだけで、ほとんどパフォーマンスには影響しない。ので、GCC-10以前とClangの場合は使わないことにした。これで一応、GCC-9/10とClangでのビルドが可能になった。

裏話

過去最も大変な更新作業だった。

まずQt6の導入で蹴躓いた。例えば、SlackLogViewerはQuaZIPに依存しているのでこれをQt6でリビルドしたのだが、こちらの依存関係のせいでQt5Compatへの依存が発生し、そのことにしばらく気がつけなかった。またQt Maintenalce Toolのろくでもない仕様にも悩まされた。従来からSlackLogViewerが依存しているQtWebEngineは正しくインストールしたのだが、実はQtWebEngineはQt6からQtWebChannelとQtPositioningへ依存するようになったらしく、これら2つはQtWebEngineをインストールするだけでは自動的にインストールされないのである。意味不明である。ちょっとQt開発者の常識を疑った。QtWebEngineの一部だけが依存していて、不要な場合にはインストールしなくても良いようにした、とか?にしても隠された仕様が多すぎる。

Qt6導入に成功しても、今度はGitHub Actionsを用いた自動ビルドという壁がある。Qt6はまだ普及途上で、様々なパッケージマネージャの類はQt5を前提としていることが多いため、環境構築からして大変だ。WindowsUbuntuはいい。私の手元に環境があるので、そちらで動作することを確認しつつ同様のコマンドを再現すればいい。しかし私はMacを持っていないので、Mac用バイナリのビルドはコミット->プッシュ->Actionsで発生したエラーの確認->原因を推測してコミット、の反復試行しか方法がない。殆ど使ったことのないMacの挙動をネット上の情報だけで推測するのは極めて辛い作業だ。homebrewの仕様は良くわからないしGCCによるビルドがどういうわけか失敗するし、もう意味不明だった。正直、Mac版サポートをやめてしまいたいと思った。もしくは誰かにMac版を継続的にメンテナンスしてほしい。

今回の更新分は2023/4/29時点でAlpha版である。今回はQt6への移行と同時にGitHub Actionsのworkflowを大幅に書き換えたのでMac版を正しくビルド、パッケージ化できているかが分からず、Macユーザーによる動作確認報告を受けた後に(特にGitHubIssue #10にあるApple Silicon上での動作確認)Beta版として公開しようと思っていた。しかし2週間近く待っても誰も応答してくれなかったので、一旦Alphaのままで公開することにした。今後もしMacユーザーからの動作確認報告があれば特に中身を修正せずBeta版、あるいはリリース版に引き上げることもあるだろう。Slackのメッセージ保存上限問題以降、SlackLogViewerのユーザーは急増したが、だからといって更新情報を逐一追いかけてデバッグしてくれる親切なユーザーがいるわけではないし、大多数は一度ダウンロードしたら更新せずにそれっきりだと思われる。デバッグの不完全さは私個人ではどうしようもないことなので諦めてほしい。


  1. ユーザー名に日本語文字などを使っていると起こる。

[Python]分散処理フレームワークRayに関する備忘録。

最近Rayによる複数PCでの分散処理をする機会があったので、気がついたことをメモしておく。Windows環境を前提としている。

ノードIPアドレスの指定

通常、ray startコマンドは以下のように実行する。

ray start --head --port=6379 (ヘッドノード側)
ray start --address=192.168.XXX.YYY (ワーカーノード側)

しかしWindowsの場合、ヘッドノード、ワーカーノードともに、自身のIPアドレスを明示的に指定しなければIPが127.0.0.1(つまりlocalhost)になってしまうらしい。
これを回避するには、以下のようにIPアドレスを明示的に与える。

ray start --head --node-ip-address=192.168.XXX.YYY --port=6379 (ヘッドノード側)
ray start --address=192.168.XXX.YYY:6379 --node-ip-address=192.168.AAA.BBB (ワーカーノード側)

ただし、IPアドレスを明示指定すると--portのオプションが機能しなくなるのか、適当な番号のポートを勝手に割り当てようとして失敗することが多発する。何故?
何度か繰り返していればそのうち6379を割り当てて成功するのだが、気持ち悪い。

管理者権限の付与

ワーカーノード側のコマンドプロンプト等は管理者として立ち上げておかないと、ヘッドノードへのアクセスができなかった。本当にそんな仕様になっているのか?

CPU数の指定

ray startの実行時、そのノードの有するCPU数はおそらく自動的にCPUのスレッド数に指定される。が、各ノードにCPUを任意個ずつ宛てがいたい場合もある。
ヘッド、ワーカーノードどちらも、ray start実行時に以下のようにオプションで指定する。

ray start ... --num-cpus=X

調査不足だが、CPU数を指定してもなおノードに処理を均等に分散せず、一部に集中的にあてがわれる場合があった。sleep(3)などで処理時間を稼ぐときちんと分散したので、多分処理時間が短すぎたりしたのでは。

カレントディレクト

スクリプト実行時、各ノードのカレントディレクトリはコマンドプロンプト立ち上げ時のディレクトリとなっていた。

これを変更したい場合は、runtime environmentを使う。

runtime_env = { "working_dir": "C:\hoge\fuga" }
ray.init(runtime_env = runtime_env)

ただしこの方法だと、指定したworking_dirの中身がすべてアップロードされノード間で共有される。大きなファイルがworking_dirに含まれていたりするとエラーを起こす。その場合、runtime_env"excludes": [ "C:\hoge\fuga\piyo.txt" ]など除外したいファイル一覧を追加する必要がある。このexcludesにはgitignoreと同様の記述方法が使える。

もしくは、リモート関数内であればos.chdir(...)などの方法で移動することはできる。