🧵 スレッドとデバイス¶
実行が計算に使うスレッド、ノードの設定(device・seed・dedicated_thread)、CUDA、プロセス全体で共有する torch の設定を説明する。
Node の引数¶
super().__init__ は次のキーワード引数を取る。いずれも省略可能。
| 引数 | 意味 | 既定 |
|---|---|---|
period |
更新と更新の間の平均の時間。実行のタイマーの秒 | 0.01 |
seed |
ノード専用の乱数生成器 self.generator の seed |
None(毎回違う) |
device |
ノードが計算する場所。"cpu" か CUDA のデバイス |
"cpu" |
dedicated_thread |
実行のワーカーでなく専用のスレッドで動かす | False |
ノードはこれらを読み取り専用の property period・generator・device・dedicated_thread で読める。
ワーカー¶
実行はノードごとにスレッドを立てない。ワーカースレッドのプールを作り、ワーカーがノードを分け合って動かす。ワーカーは 1 つのノードの hook を 1 回実行したら、期限の早い更新から次の仕事を取る。ノードが多くてもスレッドは少ないままで、OS がスレッドの切り替えに時間を取られない。
- ワーカーの数は
mujou.run(net, workers=n)で決める。既定はプロセスが使える CPU の数(論理コア数。CPU の affinity や割り当ての上限があればそれより少ない)、GIL が有効ならその半分で、ワーカーに載るノードの数を超えない - 1 つのノードの hook が同時に走ることはなく、前の hook が書いた値は次の hook から見える。どのワーカーがノードを動かすかは hook ごとに変わりうる
- ブロッキングする hook はその間ワーカーをふさぎ、ほかのノードの
dtが伸びる(ノードが守る規則)
GIL が有効なとき(GIL ありのビルド、または free-threaded のビルドで PYTHON_GIL=1 のとき)は、Python のコードを同時に実行できるスレッドは 1 本だけなので、ノードが並列に動くのは torch の演算の中に限られる。CPU の数の半分を超えてワーカーを増やしても GIL を取り合うだけなので、既定を半分にしている。Python の処理が大半を占めるノードなら、さらに少なくても速さはほとんど変わらない。
専用のスレッド¶
ノードが専用のスレッドを要るときは dedicated_thread=True を渡す。
- hook がデバイス・ソケット・lock を待ってブロッキングする。ワーカーの上だと、その間ワーカーをふさぐ
- 1 回だけ設定するスレッドごとの状態に頼る。
on_startはこのノードのスレッドで呼ばれるので、そこで設定する
class Camera(Node):
def __init__(self) -> None:
super().__init__(period=1 / 30, dedicated_thread=True)
スレッドごとの設定¶
ワーカーの上では、ノードの hook ごとに別のスレッドで走りうるうえ、その間スレッドはほかのノードを動かす。hook がスレッドに設定したものを次の hook が見るかどうかはスケジュール次第で、同じワーカーで次に走る別のノードにも見える。
threading.local・threading.current_thread()・threading.get_ident()は hook ごとに変わりうるtorch.autocastや勾配の記録などスレッドごとの torch の設定は、hook の中のwithに閉じ(with torch.no_grad():)、hook を抜ける前に戻す。戻さないと同じワーカーで次に走るノードに漏れるcontextvarsは持ち越される。各ノードは自分専用のcontextvars.Contextの中で hook を実行する。この文脈は空から始まり、ホストの文脈を引き継がない
mujou 自身が設定するのは、CUDA のノードの hook の前ごとの現在のデバイスとストリームだけ。
seed と乱数¶
乱数はノード自身の生成器 self.generator から引く。生成器は seed から作られ、ノードのデバイス上にある。super().__init__() の直後から、__init__ の中でも使える。
nn.init.normal_(self.model.weight, std=0.1, generator=self.generator)
noise = torch.randn(4, generator=self.generator, device=self.device)
seed で再現できるのは self.generator だけ。nn.Linear などの既定の初期化は全ノードが共有する生成器を使うので、self.generator で初期化し直す。ノードの中で torch.manual_seed を呼ばない。ほかのノードの乱数まで変わる。self.generator の状態はノードの状態に入るので、再開した実行は同じ乱数列の続きから引く。
デバイス¶
device は CPU か CUDA のデバイス。それ以外("mps" や "meta" など)は ValueError になり、CUDA が使えない環境での CUDA も ValueError になる。受け取ったメッセージはノードのデバイスに届くので、テンソルは device=self.device で確保する。番号を付けない "cuda" は、ノードを作った時点のホストの現在のデバイスになる。
CUDA のノードには、mujou が次のことをする。
- ノードの各 hook の中で、ノードのデバイスとノード専用の CUDA ストリームを現在のものにする。ノードの演算はすべてこのストリームに積まれる
- 各
on_updateの後と状態の復元の後に、そのストリームに積んだ仕事を待つ(その間は、ブロッキングする hook と同じくワーカーが待つ) state_dictの前後にも待つ。ワーカーでなく mujou の別のスレッド(実行の終了後はホストのスレッド)で行うので、ワーカーは待たない- ノードが自分のデバイスの上のテンソルを送ると、そのポートを CUDA のノードが読むなら、そのデバイスに置いたままにする。
sendはノードのストリームの上でコピーし、相手が読めるようになる前にそのストリームを待つ。CUDA の相手はデバイスからデバイスへコピーし、ホストのメモリを経由しない(ピアアクセスの無い 2 つの GPU の間では、CUDA のドライバがホストを中継することはある)。CPU のノードとホスト(run.read、subscribe、wait_for、state_dict)は、読むときに CPU のコピーを受け取る。CPU のノードだけ(またはホストだけ)が読むポートは、そのほうが速いのでホストのメモリを経由して送る。ほかのデバイスのテンソルも同じ。hook の中で別のデバイスを現在のものにしている間(torch.cuda.device(...))も、メッセージはデバイスに置いたまま渡す
そのためノードは同期を書かなくてよい。自分で同期するときは自分のストリームだけを待つ(torch.cuda.current_stream().record_event().synchronize())。デバイス全体を待つ torch.cuda.synchronize() はほかのノードの仕事まで待つので使わない。ストリームはデバイスごとに 32 本のプールから割り当てるので、1 つのデバイスで 32 本を超えると、いくつかのノードが 1 本を共有する。別のデバイスを読むノードのコピー用のストリーム(下記)もこの数に入る。共有しても結果は正しいが、遅くなることがある。書き手とストリームを共有した読み手は、書き手が送った後に積んだ仕事を待ち、CPU のノードならホストでも待つ。
CUDA のテンソルとモジュールは on_start で作る
mujou.run がノードを起動する前にホストのストリームを同期するので、__init__ で CUDA に作っても正しく動く。それでも on_start 以降で作るほうがよい。PyTorch のキャッシュアロケータはメモリを確保した時点のストリームに結び付けるので、__init__ で確保したメモリはホストのストリームに結び付いたまま残る。ノードがそのテンソルを捨てると、ノードの計算が終わる前にホストの確保に再利用されうる。復元は on_start の後に状態を読み込むので、そこで作った状態も復元される。
別のデバイスの CUDA のノードからメッセージを受け取るノード(CPU のノードや、別の GPU のノード)は、そのデバイスの上の自分専用のストリームでコピーする。mujou はこのストリームも各 hook の中で現在のものにする。run.write(と inputs=)も、ポートを持つノードの CUDA のデバイスの上のテンソルはそのデバイスに置く。ホストのそのデバイスの現在のストリームでコピーし、そのストリームを待つので、現在のデバイスがどれでも、ホストが同期を書く必要は無い。それ以外のテンソル(CPU のノードへの書き込みや、ノードと別のデバイスのテンソル)はホストのメモリを通す。こうすると、ホストが書き込みの後に GPU に積んだ仕事をどのノードも待たない。CUDA のノードから受け取らない CPU のノードは CUDA に触れない。直前に CUDA のノードを動かしたワーカーではそのノードのストリームが現在のまま残るが、CPU のノードはそれを使わない。
状態の木は CUDA のノードの状態も CPU のコピーで持ち、既定の load_state_dict がノードのデバイスへコピーし直す。ただし乱数生成器の状態は CPU と CUDA で違うので、CUDA のノードから保存した木を復元できるのは CUDA のノードだけ(別の GPU のマシンでもよい)。
プロセス全体の設定¶
次の設定はプロセス全体で共有されるので、ノードの中では変えない。ホストが mujou.run の前に 1 回だけ設定する。
torch.manual_seedと既定の乱数生成器torch.set_num_threads、torch.use_deterministic_algorithms、torch.backends.*のフラグtorch.cuda.synchronize()、torch.cuda.empty_cache()などデバイス全体に効く操作
CPU 上の torch の演算は、計算するスレッドごとにさらに内部のスレッドを使う。多すぎるとノード同士でコアを取り合い、全体が遅くなるので、ホストが少なくしておく。
# The nodes compute on the run's worker threads, and torch adds threads of its
# own for each: keep those few (a setting of the whole process, made by the host).
torch.set_num_threads(1)
スレッド数の目安
ワーカーの数に dedicated_thread=True のノードの数を足し、torch.set_num_threads(n) の n を掛けた値が物理コア数を超えないようにする。小さな行列を扱うノードが多いときは n = 1 でよい。