コンテンツにスキップ

🚀 1. ネットワークを走らせる

最小のプログラムを走らせ、1 行ずつ説明する。2 つのノードで \(y = 2x + 1\) を計算するプログラムである。後半ではネットワークを分岐させ、合流させる。

flowchart LR
    H1["ホスト"] -->|"x を書く"| S["scale(2 倍)"]
    S --> F["shift(+1)"]
    F -->|"y を読む"| H2["ホスト"]

プログラム

examples/quickstart.py
"""The smallest program: two nodes in a network, running in the background.

The host writes an input, waits until the output arrives, and stops.
"""

from typing import override

import torch

import mujou
from mujou import Network, Node, Port, TensorSpec

VECTOR = TensorSpec((3,))  # every message here is a float32 vector of 3


class Affine(Node):
    """Sends ``a * x + b``, where ``x`` is the latest input."""

    def __init__(self, a: float, b: float) -> None:
        super().__init__()
        self.a, self.b = a, b  # settings, not state
        self.prev = Port(recv=VECTOR)  # receives from the previous node
        self.next = Port(send=VECTOR)  # sends to the next node

    @override
    def on_update(self, dt: float) -> None:
        super().on_update(dt)
        self.next.send(self.a * self.prev.recv() + self.b)  # recv() never waits




class Pipeline(Network):
    """``x -> scale -> shift ->``: y = 2 x + 1."""

    def __init__(self) -> None:
        super().__init__()
        self.scale = Affine(2.0, 0.0)
        self.shift = Affine(1.0, 1.0)
        self.scale.next.connect(self.shift.prev)
        self.prev, self.next = self.scale.prev, self.shift.next  # exposed as its own




net = Pipeline()
x = torch.tensor([1.0, 2.0, 3.0])
# The nodes run on the run's worker threads until the end of the `with` block.
with mujou.run(net) as run:
    run.write({net.prev: x})
    # x takes a few updates to pass through the nodes: wait until y arrives, with
    # the time it was sent (TimeoutError after 5 s of real time).
    y, t = run.wait_for(net.next, lambda y: torch.equal(y, 2 * x + 1), timeout=5.0)
    assert t is not None  # None only for the zeros before the first send

print(f"y = {y} at t = {float(t):.3f} s")

リポジトリのルートで次のように実行する。

uv run python examples/quickstart.py

次のような 1 行が表示される。

y = tensor([3., 5., 7.]) at t = 0.017 s

y は x = [1, 2, 3] に対する \(2x + 1\) である。t は shift が y を送った時刻で、単位は実行のタイマーの秒。値は実行ごとに変わる。

ノード

class Affine(Node):
    """Sends ``a * x + b``, where ``x`` is the latest input."""

    def __init__(self, a: float, b: float) -> None:
        super().__init__()
        self.a, self.b = a, b  # settings, not state
        self.prev = Port(recv=VECTOR)  # receives from the previous node
        self.next = Port(send=VECTOR)  # sends to the next node

    @override
    def on_update(self, dt: float) -> None:
        super().on_update(dt)
        self.next.send(self.a * self.prev.recv() + self.b)  # recv() never waits
  • ノードは mujou.Node を継承したクラス。どのサブクラスとも同じく、__init__ では最初に super().__init__() を呼び、各 hook でも最初に super() の同じ hook を呼ぶ
  • __init__ でノードの Port を属性として宣言する。Port(recv=VECTOR) は長さ 3 の float32 のベクトルを受け取り、Port(send=VECTOR) はそれを送る。慣習では前のノード側の Port を prev、次のノード側を next と名付ける
  • a と b は設定。引数から決めた数値で、状態ではない
  • on_update(dt) はノードの 1 回分の処理。mujou がおよそ 10 ms ごとに繰り返し呼ぶ。recv() は相手が送った最新の値を待たずに返し、send() はすべての相手にコピーを送る

ネットワーク

class Pipeline(Network):
    """``x -> scale -> shift ->``: y = 2 x + 1."""

    def __init__(self) -> None:
        super().__init__()
        self.scale = Affine(2.0, 0.0)
        self.shift = Affine(1.0, 1.0)
        self.scale.next.connect(self.shift.prev)
        self.prev, self.next = self.scale.prev, self.shift.next  # exposed as its own

ネットワークはノードを属性として持ち、その Port を connect でつなぎ、外から使う Port を自分の属性として公開する(self.prev、self.next)。connect は互いの送る spec が相手の受け取る spec と一致するかを確かめ、一致しなければ TypeError を送出する。

実行

net = Pipeline()
x = torch.tensor([1.0, 2.0, 3.0])
# The nodes run on the run's worker threads until the end of the `with` block.
with mujou.run(net) as run:
    run.write({net.prev: x})
    # x takes a few updates to pass through the nodes: wait until y arrives, with
    # the time it was sent (TimeoutError after 5 s of real time).
    y, t = run.wait_for(net.next, lambda y: torch.equal(y, 2 * x + 1), timeout=5.0)
    assert t is not None  # None only for the zeros before the first send

print(f"y = {y} at t = {float(t):.3f} s")
  • mujou.run(net) はネットワークを検査して全ノードを起動し、動き出したら Run を返す。各ノードは実行のワーカースレッドの上で on_update を繰り返し、ホストのスレッドは先へ進む
  • run.write はつながっていない Port net.prev に x を書く。値は次に書くまで残る
  • recv() は待たないので、x が scale を通り抜けるまで shift は前の入力(最初はゼロ)から計算した値を送り続ける。run.wait_for は条件を満たした最初の出力を時刻と一緒に返す。実時間で 5 秒たつと TimeoutError を送出する
  • with ブロックを抜けると実行が終了する。ノードの例外も遅くともそこでホストに届く

分岐と合流

送るだけの Port は相手をいくつでも取れる。複数の Port につなげば、送った値はそれぞれに届く(fan-out)。合流には MultiPort を使う。その recv() は Channel ごとに値を 1 つずつ、connect の順の dict[Channel, 値] で返し、どう束ねるかはノードが決める。

class Apply(Node):
    """Sends ``fn(x)``."""

    def __init__(self, fn: Callable[[torch.Tensor], torch.Tensor]) -> None:
        super().__init__()
        self.fn = fn
        self.prev = Port(recv=VECTOR)
        self.next = Port(send=VECTOR)  # only sends: any number of peers

    @override
    def on_update(self, dt: float) -> None:
        super().on_update(dt)
        self.next.send(self.fn(self.prev.recv()))


class Sum(Node):
    """Sends the sum of the latest messages of all its peers."""

    def __init__(self) -> None:
        super().__init__()
        self.prev = MultiPort(recv=VECTOR)  # merges any number of peers
        self.next = Port(send=VECTOR)

    @override
    def on_update(self, dt: float) -> None:
        super().on_update(dt)
        received = self.prev.recv()  # dict[Channel, torch.Tensor], in connect order
        self.next.send(torch.stack(list(received.values())).sum(dim=0))


class Diamond(mujou.Network):
    """``shift -> (double, square) -> sum``: y = 2 (x + 1) + (x + 1)^2."""

    def __init__(self) -> None:
        super().__init__()
        self.shift = Apply(lambda x: x + 1)
        self.double = Apply(lambda x: 2 * x)
        self.square = Apply(lambda x: x**2)
        self.sum = Sum()
        self.shift.next.connect(self.double.prev)  # one port, two peers
        self.shift.next.connect(self.square.prev)
        self.double.next.connect(self.sum.prev)  # two peers, one MultiPort
        self.square.next.connect(self.sum.prev)
        self.prev, self.next = self.shift.prev, self.sum.next

Diamond は \(y = 2(x + 1) + (x + 1)^2\) を計算する。shift が両方の枝へ送り、sum が 2 つの枝の値を足す。受け取る Port の recv() は値を 1 つ返すので、受け取る相手は 1 つだけ。複数の相手から受け取るなら MultiPort を使う。uv run python examples/branching.py で実行できる。同じプログラムの後半は相手ごとに違う値を送る。説明は Port と Channel にある。

次へ