🍇

ラズパイでクラスターをつくりました|Ubuntu server 25.04

に公開

最近、並列計算に興味をもったのでクラスターを構築して計算させてみたいなと思いました。
会社の部長からラズパイをたくさんお借りることができたので、環境構築とPythonで素数を計算した際の挙動を確認したいと思います。(サンキュー部長)

構築する手順をこの記事に記録したいと思います。

ラズパイクラスター構成

今回は三台のラズパイ5を使ってクラスターを構築したいと思います。
本当は五台持ってる(もともと一台持ってる、部長から四台借りた)のですが、まず動作確認したいのと電源が足りないので三台で、まずは三台で動作させます。

クラスターの役割分担

クラスターでは全てのノード(今回は各ラズパイ)が同じ計算をすることもできますが、実用上は一つが指示出し役(計算を割り振ったりする)、他が計算役(実際に計算する)に分かれていたりします。

今回は三台のラズパイは一つをMaster 役にして、ほか二つはWorker 役にしました。
また、わかりやすいように以下のようにそれぞれのノードに名前をつけました。

pi0 master
pi1 worker1
pi2 worker2

これらの名前は後にHostnameにします。

機材と配線

用いた機材は以下の通りです。

  • ラズパイ5 ×3
  • LANケーブル ×3
  • SDカード (Ubuntu server 25.04) ×3
  • スイッチ
  • ディスプレイ
  • キーボード
  • 上記を動かすための電源(これが意外と大変)

これらを以下のように配線しました。
キーボードやディスプレイはMaster として振る舞うpi0 に接続します。(SSHが通るまでは必要に応じてpi1、pi2につなぎもしました。めんどかった)

構成図 チャッピー作
                ┌───────────┐
                │  Switch   │
                └─────┬─────┘
          ┌───────────┼───────────┐
          │           │           │
     ┌────┴────┐ ┌────┴────┐ ┌────┴────┐
     │  pi0    │ │  pi1    │ │  pi2    │
     │ master  │ │ worker1 │ │ worker2 │
     └────┬────┘ └─────────┘ └─────────┘
          │
   ┌──────┴──────┐
   │ Keyboard    │
   └─────────────┘
   ┌─────────────┐
   │ Display     │
   └─────────────┘

SSHするまで

IPを固定

各ノードでEthernet のIPを固定しました。pi0、pi1、pi2 をそれぞれ 192.168.1.10、.11、.12としました。固定方法はたくさん記事がありますが、この記事がわかりやすいと思います。

※ついでにWi-Fiの設定などもしておくとあとで apt updateするときやライブラリのインストールで便利です。

とりあえず、ラズパイ同士がLAN経由でやり取りできるかを確認します。

mester にて
ping -c 3 192.168.1.11
ping -c 3 192.168.1.12

ホスト名の変更

pingで通信ができたら、次はSSHの設定をします。
まずは、これからSSHを多様するので、各ノードのホスト名を変更しておきます。
今回はMaster、Worker1、Worker2としました。

それぞれのノードにて
sudo hostnamectl set-hostname master
sudo hostnamectl set-hostname worker1
sudo hostnamectl set-hostname worker2

さらに、これらのIPとホスト名を紐づけるために/etc/hostsを追記します。
これはすべてのノードで行います(統一させます)。

/etc/hosts すべてのノードにて
192.168.1.10 master
192.168.1.11 worker1
192.168.1.12 worker2

SSH接続

SSHで接続するにはパスワードによる認証か鍵認証が必要です。
作業のながれはこんな感じです。

  1. Master にて鍵を作成
  2. Worker にてパスワードでログインできるよに設定
  3. Master からWorker にパスワードでSSH接続し、作成した鍵を共有
  4. Master からWorker にパスワードなしでSSHできるか確認
  5. Worker のパスワードログインを無効化

まずはMaster にてSSHのための鍵を作成します。
以下のコマンドを打つと色々聞かれますが、基本Enter 連打で大丈夫かと

Master にて
ssh-keygen -t rsa -b 4096

鍵が作成されているか確認する場合はこれです。二つのファイルがあればOKです。
このとき、.pubが公開鍵です。こちらをWorker に渡します。

Master にて
ls -l ~/.ssh/id_rsa*

この鍵を共有するために、一度Worker 側はパスワードで入れるように設定します。
以下はデフォルトでは noであるかコメントアウトされています。
編集が終わったらそれを反映させます。

/etc/ssh/sshd_config Worker にて
PasswordAuthentication yes
KbdInteractiveAuthentication yes #あれば
Worker にて
sudo netplan apply

次にMaster で作成した鍵をWorker に共有します。
これを実行するとWorker のパスワードが求められ、ログインできると同時に鍵が共有されます。

Master にて
ssh-copy-id ubuntu@worker1
ssh-copy-id ubuntu@worker2

これでパスワードなしでもSSHが繋がると思うので、テストします。

Master にて
ssh ubuntu@worker1
ssh ubuntu@worker2

無事、繋がったらWorker のパスワード認証を無効にします。

/etc/ssh/sshd_config Worker にて
PasswordAuthentication no
KbdInteractiveAuthentication no #あれば

netplan apply もお忘れなく

MPIの環境構築

並列計算の環境構築ですが、各ノードで全く同じ環境を用意する必要があります。
なので、以下の手順はすべてのノードで行います。
一部Master でのみ行って、SSHでファイル転送するのが楽だったりもします。

ライブラリのインストール

必要なライブラリをインストールします。

各ノードにて
# MPIのライブラリ
sudo apt install -y openmpi-bin openmpi-common libopenmpi-dev

# Python系
python3 -m venv ~/mpi-env
source ~/mpi-env/bin/activate   # 仮想環境を起動
pip install mpi4py

slotsの指定

MPIの計算で使用する各ノードにおけるプロセス数も指定します。
起動可能なプロセスを示したファイル(hostfile、今回はhosts というファイル名にした)を各ノードに作成します。

なお、Master でのみ作成し、その後SSH のファイル転送でWorker へ配布すると楽です。

hosts
master  slots=4
worker1 slots=4
worker2 slots=4

SSHでノード間のコピペ↓

Master にて
scp hosts worker1:~
scp hosts worker2:~

MPIの動作確認用コードの準備

並列計算では実行ファイルはすべてのノードに配布する必要があるため、slots のhostfile 同様、Master でのみ記述し、SSH 間でコピペしました。

check_rank.py
from mpi4py import MPI
import socket

comm = MPI.COMM_WORLD
rank = comm.Get_rank()
size = comm.Get_size()
host = socket.gethostname()

print(f"rank {rank}/{size} running on {host}")

このコードは動いているプロセス数と、それがどのホストによって動作されているのかを表示します。

SSHでコピペ↓

Master にて
scp check_rank.py worker1:~
scp check_rank.py worker2:~

実行は仮想環境

いよいよMPIを動作させます。

Worker は仮想環境の設定は必要ですが、MPIを動作させる際は仮想環境に入る必要はありません。なので、Master のみ入ります。

実行する際は、オプションで動作させるプロセス数-npとホスト名--hostを指定します。

Master にて
source ~/mpi-env/bin/activate
mpirun -np 3 --host master,worker1,worker2 ~/mpi-env/bin/python check_rank.py
実行結果
rank 1/3 running on worker1
rank 2/3 running on worker2
rank 0/3 running on master

実際に並列計算

動かしたコード

今回動かすコードは、2~10の7乗までの数字から素数を抽出し、その計算にかかった時間を計るものです。

Master は1000個ずつ数字を渡し、Worker は受け取った数字から素数を探し出しMaster に返します。なぜ、数値を少しずつ渡していくのかについては、過去の記事を参考にしていただければと思います。

もちろん、このコードもMaster だけでなく、Worker にもscpで配布します。

prime_nums.py
from mpi4py import MPI
import time

comm = MPI.COMM_WORLD
rank = comm.Get_rank()
size = comm.Get_size()

MASTER = 0
END = -1

# --------------------
# 素数判定
# --------------------
def is_prime(n):
    if n <= 1:
        return False
    elif n == 2:
        return True
    elif n % 2 == 0:
        return False

    limit = int(n ** 0.5) + 1
    for i in range(3, limit, 2):
        if n % i == 0:
            return False
    return True

# --------------------
# master
# --------------------
if rank == MASTER:
    N = 10**7
    tasks = list(range(2, N))
    task_duration = 1000
    next_task_start = 0
    primes = []

    t_start = time.time()

    # 最初に worker に1つずつ渡す
    for worker in range(1, size):
        if next_task_start < len(tasks):
            end = min(next_task_start + task_duration, len(tasks))
            comm.send(tasks[next_task_start:end], dest=worker)
            next_task_start += task_duration

    finished = 0
    while finished < size - 1:
        result, worker = comm.recv(source=MPI.ANY_SOURCE)

        if result is not None:
            primes.extend(result)

        if next_task_start < len(tasks):
            end = min(next_task_start + task_duration, len(tasks))
            comm.send(tasks[next_task_start:end], dest=worker)
            next_task_start += task_duration
        else:
            comm.send(END, dest=worker)
            finished += 1

    t_end = time.time()
    print(f"found {len(primes)} primes")
    print(f"time: {t_end - t_start:.3f} sec")

# --------------------
# worker
# --------------------
else:
    t_local_start = time.time()

    while True:
        tasks: list[int] = comm.recv(source=MASTER)

        if tasks == END:
            break

        local_primes = []
        for task in tasks:
            if is_prime(task):
                local_primes.append(task)
        
        comm.send((local_primes if local_primes else None, rank), dest=MASTER)

実行するには

実際に上のコードをMPIで動かしてみます。
今回のコードではノード間での通信があるので、使用するネットワークを指定する必要があります。

現在ラズパイはEthernetケーブルとWi-Fiに繋がっています。ノード間の通信(TCPによるもの)をEthernetと指定したいので、オプションで--mca btl tcp,selfと--mca btl_tcp_if_include eth0を追加します。

ネットワークを指定しないと、「TCPがうまく行かなかったですよ」的なエラーが出ます。

Master にて
mpirun -np 5 \
  --hostfile ~/hosts \
  --map-by slot \
  --mca btl tcp,self \
  --mca btl_tcp_if_include eth0 \
  /home/ubuntu/mpi-env/bin/python \
  /home/ubuntu/prime_nums.py

めっちゃ長いですが、これがコマンドです。

実行結果

実験結果は以下のようになりました。
プロセスを増やすごとに、計算に費やした時間は減って行きました。

worker一人、-np 2
time: 67.870 sec
worker二人、-np 3
undervoltage detected!
time: 34.736 sec
worker二人、-np 5
undervoltage detected!
time: 21.262 sec

ただ、ラズパイを3台とも動作させると、undervoltageが検出されました。単純に電源タップから持ってきてる電圧が足りなかったそうです。動きはしたのですが、本来ならもう少し早く計算が終わるのかもしれません。

Discussion