本当は怖いHPC

HPC屋の趣味&実益ブログ

Pyhtonでクロージャを使おうとして、Pythonの変数スコープにハマった話

クロージャは便利です。

以下のようなコードを書いて、期待と動作が違ったので数時間を溶かしてしまいました。詳しい人から見れば常識なのでしょうが…

import threading
import itertools
import time

A = [1,2,3]
B = ['a', 'b', 'c']

def main():
  for prod in itertools.product(A,B):
    # `prod` を束縛したクロージャを作る
    def f():
      for i in range(10):
        # このクロージャは、prodの値を10回表示して終了する
        print(prod)
        time.sleep(0.5)
    if prod == (1,'a'):
      # `prod`の値が (1,'a')のときだけクロージャ発動
      t = threading.Thread(target=f)
      t.start()
    time.sleep(1)

main()

さて、クロージャfは変数prodを束縛して、それをただ表示させているだけです。prodの値が(1,'a')のときだけクロージャを作成して起動しているのですから、出力は以下のようになると期待しました。

# 期待される出力
(1, 'a')
(1, 'a')
(1, 'a')
(1, 'a')
(1, 'a')
(1, 'a')
(1, 'a')
(1, 'a')
(1, 'a')
(1, 'a')

ところが実際の出力はこうなります

# 実際の出力
(1, 'a')
(1, 'a')
(1, 'b')
(1, 'b')
(1, 'c')
(1, 'c')
(2, 'a')
(2, 'a')
(2, 'b')
(2, 'b')

こうなる理由は以下のとおりです。スコープ外の変数を参照するクロージャを作った場合、そのクロージャのスコープに束縛されるのではなく、スコープ内で代入されない限りは上位レベルのスコープが参照されます。なので上位スコープ(参照サンプルコードの場合はグローバルスコープ)のprodの値が参照されるので、forループによって変数の値が変更されると、それを反映してしまいます。一般的に、このルールはグローバル変数への適用において遭遇することが多いですが、関数スコープ内でクロージャを作成するときにも適用されるんですね。

コードを以下のように直せば、期待通りに動きます

import threading
import itertools
import time

A = [1,2,3]
B = ['a', 'b', 'c']

def main():
  for prod in itertools.product(A,B):
    # `prod` を束縛したクロージャを作る
    def f():
      prod2 = prod  # <---- 新たな変数prod2へ代入する
      for i in range(10):
        # このクロージャは、prod2の値を10回表示して終了する
        print(prod2)
        time.sleep(0.5)
    if prod == (1,'a'):
      # `prod`の値が (1,'a')のときだけクロージャ発動
      t = threading.Thread(target=f)
      t.start()
    time.sleep(1)

main()

まとめ

Hy使おう(錯乱)

UCXを試す日記(8)

(追記:完全にグダグダなただの日記なので、タイトルに「日記」と付け足しました)

ここまで、UCXのディストリビューションに含まれている uct_hello_world.c を自作C++ラッパーに移植するという目標で勉強をしてきたのですが、C++ラッパーの実装の方針を大幅に変更することにしました。

これまではC++での完全なラッパーを目指していたので、コンポーネントの論理的な階層とC++のクラスクラス階層を一致させ、メモリ管理は std::shared_ptr を使うという方針でした。

しかし、 * コンポーネント間の依存性が複雑で循環参照を避けるのが面倒なこと * 単一の shared_ptr のインスタンスをあちこちコピーし回す必要があり、関数の関数の引数が増えたり、クラスのメンバ関数が不必要に増えたりして却って複雑になってしまうこと などの事情が見えてきました。

なので、よりシンプルな、非常に薄いラッパーに路線変更するか、そもそもC++ラッパーを作るのをやめるという方針のどちらかに方向転換するよていです。

実際にアプリを書きながら、考えてみようと思います。

とりあえずは、ディープラーニングで定番である Allreduce 関数の実装でもしてみようかと思っています。

MPIでRank順に出力する

小ネタ。 MPIのプログラムから printf などを使ってデバッグ出力をする場合、全プロセスから一斉に同じ出力をしたときの順序は保証されていません。

例えば、下のようなプログラムを考えます。

#include <stdio.h>
#include <mpi.h>

int main(int argc, char **argv) {
    int rank;

    MPI_Init(&argc, &argv);
    MPI_Comm_rank(MPI_COMM_WORLD, &rank);
    printf("I'm rank %d\n", rank);
    MPI_Finalize();
}
# 実行例
$ mpiexec -n 10 ./a.out
I'm rank 4
I'm rank 5
I'm rank 6
I'm rank 7
I'm rank 9
I'm rank 0
I'm rank 1
I'm rank 8
I'm rank 2
I'm rank 3

この順序を制御して、例えばRank順に出力したいということは頻繁にあります。MPIを使っている人なら、だいたい自前で書いてしまう処理で、以下のように簡単に書けます。

    for (i = 0; i < size; i++) {
        if (i == rank) {
            printf("I'm rank %d\n", rank);
        }
        MPI_Barrier(MPI_COMM_WORLD);
    }
# 実行例
$ mpiexec -n 10 ./a.out
I'm rank 0
I'm rank 1
I'm rank 2
I'm rank 3
I'm rank 4
I'm rank 5
I'm rank 6
I'm rank 7
I'm rank 8
I'm rank 9

次に、mpi4pyを使ったPythonスクリプトでも簡単に書きたいなーと思ったので書いてみました。 ループを書くのは面倒なので with 構文で書けないかなーと思い、やってみたら意外と簡単にできました。 with便利。

class RankOrdered(object):
    def __init__(self, comm):
        self._comm = comm

    def __enter__(self):
        for i in range(0, comm.rank):
            comm.barrier()
    
    def __exit__(self, exception_type, exception_value, traceback):
        for i in range(comm.size - comm.rank):
            comm.barrier()

# 利用例
from mpi4py import MPI
with RankOrdered(MPI.COMM_WORLD):
    print("rank {}".format(comm.rank))
【広告】