web-dev-qa-db-ja.com

2つの「集計」関数(sumなど)を同時に実行し、同じイテレーターからそれらをフィードする方法は?

iter(range(1, 1000))のようなイテレータがあると想像してください。また、sum()max()のように、それぞれが唯一のパラメーターとしてイテレーターを受け入れる2つの関数があります。 SQLの世界では、それらを集計関数と呼びます。

イテレータ出力をバッファリングせずに両方の結果を取得する方法はありますか?

これを行うには、集計関数の実行を一時停止して再開し、それらを保存せずに両方に同じ値を供給する必要があります。たぶん、スリープなしで非同期のものを使用してそれを表現する方法はありますか?

29

2つの集計関数を同じイテレータに適用する方法を考えてみましょう。これは1回しか使い果たせません。最初の試み(簡潔にするためにsummaxをハードコードしますが、任意の数の集計関数に簡単に一般化できます)は次のようになります。

def max_and_sum_buffer(it):
    content = list(it)
    p = sum(content)
    m = max(content)
    return p, m

この実装には、両方の関数が完全にストリーム処理を実行できるにもかかわらず、生成されたすべての要素を一度にメモリに格納するという欠点があります。質問はこのコップアウトを予期し、イテレータ出力をバッファリングせずに結果が生成されることを明示的に要求します。これを行うことは可能ですか?

シリアル実行:itertools.tee

それは確かに可能のようです。結局のところ、Pythonイテレータは external であるため、すべてのイテレータはすでにそれ自体をサスペンドすることができます。同じコンテンツを提供する2つの新しいイテレータへのイテレータ?確かに、これは itertools.tee の説明であり、並列反復に完全に適しているように見えます。

def max_and_sum_tee(it):
    it1, it2 = itertools.tee(it)
    p = sum(it1)  # XXX
    m = max(it2)
    return p, m

上記は正しい結果を生成しますが、私たちが望むようには機能しません。問題は、並行して反復していないことです。 summaxのような集計関数は、中断することはありません。それぞれが、結果を生成する前にすべてのイテレータコンテンツを消費することを要求します。したがって、sumが実行される前に、maxit1を使い果たします。 it1をそのままにしてit2の要素を使い果たすと、それらの要素は2つのイテレータ間で共有される内部FIFOに蓄積されます。これは、ここでは避けられません-max(it2)は同じように見える必要があるためです。要素の場合、teeはそれらを蓄積する以外に選択肢はありません(teeの詳細については、 この投稿を参照してください。

言い換えると、最初の実装が少なくともバッファリングを明示的にすることを除いて、この実装と最初の実装の間に違いはありません。バッファリングを排除するには、summaxを順番にではなく、並行して実行する必要があります。

スレッド:concurrent.futures

teeを使用して元のイテレーターを複製しながら、集計関数を別々のスレッドで実行するとどうなるかを見てみましょう。

def max_and_sum_threads_simple(it):
    it1, it2 = itertools.tee(it)

    with concurrent.futures.ThreadPoolExecutor(2) as executor:
        sum_future = executor.submit(lambda: sum(it1))
        max_future = executor.submit(lambda: max(it2))

    return sum_future.result(), max_future.result()

現在、summaxは実際には並行して実行され( GIL が許可する限り)、スレッドは優れた によって管理されます。 concurrent.futures モジュール。ただし、致命的な欠陥があります。teeがデータをバッファリングしないためには、summaxがまったく同じ速度でアイテムを処理する必要があります。一方が他方よりも少しでも速い場合、それらはバラバラになり、teeはすべての中間要素をバッファリングします。それぞれの実行速度を予測する方法がないため、バッファリングの量は予測不可能であり、すべてをバッファリングするという厄介な最悪のケースがあります。

バッファリングが発生しないようにするには、teeを、何もバッファリングせず、すべてのコンシューマが前の値を確認してから次の値に進むまでブロックするカスタムジェネレータに置き換える必要があります。以前と同様に、各コンシューマーは独自のスレッドで実行されますが、呼び出し元のスレッドはプロデューサーの実行でビジー状態になります。ループは実際にソースイテレーターを反復処理し、新しい値が使用可能であることを通知します。実装は次のとおりです。

def max_and_sum_threads(it):
    STOP = object()
    next_val = None
    consumed = threading.Barrier(2 + 1)  # 2 consumers + 1 producer
    val_id = 0
    got_val = threading.Condition()

    def send(val):
        nonlocal next_val, val_id
        consumed.wait()
        with got_val:
            next_val = val
            val_id += 1
            got_val.notify_all()

    def produce():
        for elem in it:
            send(elem)
        send(STOP)

    def consume():
        last_val_id = -1
        while True:
            consumed.wait()
            with got_val:
                got_val.wait_for(lambda: val_id != last_val_id)
            if next_val is STOP:
                return
            yield next_val
            last_val_id = val_id

    with concurrent.futures.ThreadPoolExecutor(2) as executor:
        sum_future = executor.submit(lambda: sum(consume()))
        max_future = executor.submit(lambda: max(consume()))
        produce()

    return sum_future.result(), max_future.result()

これは、概念的には非常に単純なもののかなりの量のコードですが、正しく操作するために必要です。

produce()は外部イテレータをループし、一度に1つの値でアイテムをコンシューマに送信します。 Python 3.2で追加された便利な同期プリミティブである Barrier を使用して、すべてのコンシューマーが古い値で完了するまで待機してから、 next_valの新しい値。新しい値の準備ができると、 condition がブロードキャストされます。consume()は、生成された値が到着すると、検出されるまで送信するジェネレータです。 STOP。ループ内にコンシューマーを作成し、バリアを作成するときにそれらの数を調整することで、コードを一般化して、任意の数の集約関数を並行して実行できます。

この実装の欠点は、スレッドの作成(スレッドプールをグローバルにすることで軽減される可能性があります)と、各反復パスでの非常に注意深い同期が必要になることです。この同期によりパフォーマンスが低下します。このバージョンは、シングルスレッドのteeよりも約2000倍遅く、単純ですが非決定論的なスレッドバージョンよりも475倍遅くなります。

それでも、スレッドが使用されている限り、何らかの形で同期を回避することはできません。同期を完全に排除するには、スレッドを放棄して協調マルチタスクに切り替える必要があります。問題は、summaxのような通常の同期関数の実行を一時停止して、それらを切り替えることができるかどうかです。

繊維:グリーンレット

greenlet サードパーティの拡張モジュールがまさにそれを可能にしていることがわかりました。 Greenletsは、 fibers の実装であり、相互に明示的に切り替わる軽量のマイクロスレッドです。これはPythonジェネレーターのようなもので、yieldを使用してサスペンドしますが、グリーンレットははるかに柔軟なサスペンドメカニズムを提供し、誰をサスペンドするかを選択できますto

これにより、スレッドバージョンのmax_and_sumをグリーンレットに移植するのがかなり簡単になります。

def max_and_sum_greenlet(it):
    STOP = object()
    consumers = None

    def send(val):
        for g in consumers:
            g.switch(val)

    def produce():
        for elem in it:
            send(elem)
        send(STOP)

    def consume():
        g_produce = greenlet.getcurrent().parent
        while True:
            val = g_produce.switch()
            if val is STOP:
                return
            yield val

    sum_result = []
    max_result = []
    gsum = greenlet.greenlet(lambda: sum_result.append(sum(consume())))
    gsum.switch()
    gmax = greenlet.greenlet(lambda: max_result.append(max(consume())))
    gmax.switch()
    consumers = (gsum, gmax)
    produce()

    return sum_result[0], max_result[0]

ロジックは同じですが、コードが少なくなっています。以前と同様に、produceはソースイテレータから取得した値を生成しますが、そのsendは、すべてがシングルスレッドの場合に必要がないため、同期を気にしません。代わりに、明示的にすべてのコンシューマーに切り替えてその処理を実行し、コンシューマーは忠実に切り替えます。すべてのコンシューマーを通過した後、プロデューサーは次の反復パスの準備ができています。

グリーンレットはターゲット関数の戻り値へのアクセスを提供しないため(また、 threading.Thread も提供しないため、結果は中間の単一要素リストを使用して取得されます。そのため、上記のconcurrent.futuresを選択しました。 )。

ただし、グリーンレットを使用することには欠点があります。まず、標準ライブラリが付属していません。グリーンレット拡張機能をインストールする必要があります。次に、スタックスイッチングコードはOSとコンパイラによって サポートされていない であり、ハックと見なすことができるため、グリーンレットは本質的に移植性がありません( 非常に賢い 1)。 A Pythonターゲティング WebAssembly または [〜#〜] jvm [〜#〜] または GraalVM がグリーンレットをサポートする可能性は非常に低いです。これは差し迫った問題ではありませんが、長期的には間違いなく覚えておくべきことです。

コルーチン:asyncio

Python 3.5、Pythonはネイティブコルーチンを提供します。グリーンレットとは異なり、ジェネレーターと同様に、コルーチンは通常の関数とは異なり、async defを使用して定義する必要があります。コルーチン同期コードから簡単に実行することはできません。代わりに、それらを完了させるスケジューラーによって処理する必要があります。スケジューラーは、イベントループとも呼ばれます。 IOイベントを受信し、それらを適切なコールバックとコルーチンに渡す。標準ライブラリでは、これは asyncio の役割です。モジュール。

非同期ベースのmax_and_sumを実装する前に、まずハードルを解決する必要があります。グリーンレットとは異なり、asyncioはコルーチンの実行のみを一時停止でき、任意の関数の実行は一時停止できません。したがって、summaxを本質的に同じことを行うコルーチンに置き換える必要があります。これは、明白な方法でそれらを実装するのと同じくらい簡単で、forasync forに置き換えるだけで、 async iterator が次の値が到着するのを待つ間、コルーチンを一時停止できるようにします:

async def asum(it):
    s = 0
    async for elem in it:
        s += elem
    return s

async def amax(it):
    NONE_YET = object()
    largest = NONE_YET
    async for elem in it:
        if largest is NONE_YET or elem > largest:
            largest = elem
    if largest is NONE_YET:
        raise ValueError("amax() arg is an empty sequence")
    return largest

# or, using https://github.com/vxgmichel/aiostream
#
#from aiostream.stream import accumulate
#def asum(it):
#    return accumulate(it, initializer=0)
#def amax(it):
#    return accumulate(it, max)

集約関数の新しいペアを提供することが不正行為であるかどうかを合理的に尋ねることができます。結局のところ、以前のソリューションでは、既存のsumおよびmaxビルトインを慎重に使用していました。答えは質問の正確な解釈に依存しますが、新しい機能は目前のタスクに固有のものではないため、許可されていると私は主張します。ビルトインとまったく同じことを行いますが、非同期イテレータを消費します。このような関数が標準ライブラリのどこかにまだ存在しない唯一の理由は、コルーチンと非同期イテレータが比較的新しい機能であるためだと思います。

これで、max_and_sumをコルーチンとして記述できます。

async def max_and_sum_asyncio(it):
    loop = asyncio.get_event_loop()
    STOP = object()

    next_val = loop.create_future()
    consumed = loop.create_future()
    used_cnt = 2  # number of consumers

    async def produce():
        for elem in it:
            next_val.set_result(elem)
            await consumed
        next_val.set_result(STOP)

    async def consume():
        nonlocal next_val, consumed, used_cnt
        while True:
            val = await next_val
            if val is STOP:
                return
            yield val
            used_cnt -= 1
            if not used_cnt:
                consumed.set_result(None)
                consumed = loop.create_future()
                next_val = loop.create_future()
                used_cnt = 2
            else:
                await consumed

    s, m, _ = await asyncio.gather(asum(consume()), amax(consume()),
                                   produce())
    return s, m

このバージョンは、グリーンレットを使用するものと同じように、単一スレッド内のコルーチン間の切り替えに基づいていますが、外観が異なります。 asyncioはコルーチンの明示的な切り替えを提供せず、タスク切り替えをawait一時停止/再開プリミティブに基づいています。 awaitのターゲットは別のコルーチンにすることもできますが、抽象的な「未来」、つまり後で他のコルーチンによって入力される値のプレースホルダーにすることもできます。待機中の値が使用可能になると、イベントループは自動的にコルーチンの実行を再開し、await式が指定された値に評価されます。したがって、produceを消費者に切り替える代わりに、すべての消費者が生成された値を観察すると到着する未来を待つことによって、それ自体を一時停止します。

consume()非同期ジェネレーター であり、非同期イテレーターを作成することを除けば通常のジェネレーターと同じです。非同期イテレーターは、集約コルーチンがasync forを使用して受け入れる準備ができています。非同期イテレータに相当する__next____anext__と呼ばれ、コルーチンです。これにより、非同期イテレータを使い果たしたコルーチンは、新しい値が到着するのを待つ間、一時停止できます。実行中の非同期ジェネレーターがawaitで一時停止すると、それはasync forによって暗黙の__anext__呼び出しの一時停止として監視されます。 consume()は、produceによって提供される値を待機し、それらが使用可能になると、それらをasumamaxなどの集約コルーチンに送信します。 。待機は、itから次の要素を運ぶnext_valfutureを使用して実現されます。 consume()内でその未来を待つと、非同期ジェネレーターが一時停止され、コルーチンが集約されます。

グリーンレットの明示的な切り替えと比較したこのアプローチの利点は、お互いを知らないコルーチンを同じイベントループに組み合わせることがはるかに簡単になることです。たとえば、max_and_sumの2つのインスタンスを(同じスレッドで)並行して実行したり、さらに非同期コードを呼び出して計算を行うより複雑な集計関数を実行したりできます。

次の便利な関数は、非同期コードから上記を実行する方法を示しています。

def max_and_sum_asyncio_sync(it):
    # trivially instantiate the coroutine and execute it in the
    # default event loop
    coro = max_and_sum_asyncio(it)
    return asyncio.get_event_loop().run_until_complete(coro)

パフォーマンス

summaxはほとんど処理を行わず、並列化のオーバーヘッドに過度のストレスがかかるため、並列実行に対するこれらのアプローチのパフォーマンスの測定と比較は誤解を招く可能性があります。大きな塩の粒で、マイクロベンチマークを処理するのと同じようにこれらを処理します。そうは言っても、とにかく数字を見てみましょう!

測定値はPython 3.6関数を1回だけ実行し、range(10000)を指定して、実行の前後にtime.time()を減算して時間を測定しました。結果は次のとおりです。

  • max_and_sum_buffermax_and_sum_tee:0.66 ms-両方ともほぼ同じ時間で、teeバージョンの方が少し高速です。

  • max_and_sum_threads_simple:2.7ミリ秒。このタイミングは、非決定論的なバッファリングのためにほとんど意味がないため、2つのスレッドを開始する時間と、Pythonによって内部的に実行される同期を測定している可能性があります。

  • max_and_sum_threads:1.29seconds、はるかに遅いオプションで、最も速いオプションよりも約2000倍遅くなります。この恐ろしい結果は、反復の各ステップで実行される複数の同期と、GILとの相互作用の組み合わせが原因である可能性があります。

  • max_and_sum_greenlet:25.5ミリ秒、初期バージョンと比較すると遅いですが、スレッドバージョンよりもはるかに高速です。十分に複雑な集計関数を使用すると、このバージョンを本番環境で使用することを想像できます。

  • max_and_sum_asyncio:351ミリ秒、グリーンレットバージョンのほぼ14倍遅い。 asyncioコルーチンはグリーンレットよりも軽量であり、それらの間の切り替えはファイバー間の切り替えよりもはるかに高速であるため、これは残念な結果です。コルーチンスケジューラとイベントループの実行のオーバーヘッド(この場合、コードがIOを実行しないため、この場合はやり過ぎです)が、このマイクロベンチマークのパフォーマンスを破壊している可能性があります。

  • max_and_sum_asyncio using uvloop :125ミリ秒。これは通常の非同期の2倍以上の速度ですが、それでもグリーンレットのほぼ5倍の速度です。

PyPy でサンプルを実行しても、大幅なスピードアップは得られません。実際、JITウォームアップを確実にするために数回実行した後でも、ほとんどのサンプルの実行はわずかに遅くなります。 asyncio関数は rewrite が非同期ジェネレーターを使用しないようにする必要があり(この記事の執筆時点でPyPyはPython 3.5)を実装しているため)、100ミリ秒未満で実行されます。これは、CPython + uvloopのパフォーマンスに匹敵します。つまり、グリーンレットと比較して優れていますが、劇的ではありません。

42
user4815162342