CUBE SUGAR CONTAINER

技術系のこと書きます。

ASRock DeskMini X600 で省スペースな Linux マシンを組んでみた

仮想マシンをいくつか作成して、簡単な検証をする時に使う Linux マシンが欲しくなった。 結果として、ASRock DeskMini X600 というベアボーンキットを使って小型のマシンを組んだ。 今回は、その内容についてメモしておく。

ASRock DeskMini X600

もくじ

概要

前述した通り、一度に数台程度の仮想マシンを動かすためのマシンが欲しくなった。 要件として考えた項目は次の通り。

  • なるべく部屋のスペースを占有しない
  • コストパフォーマンスで見劣りしない
  • さほど消費電力が大きくない
    • 動作時に数十ワット程度を想定する
  • なるべく動作音が小さい
  • 何らかの GNU/Linux ディストリビューションが動作する
    • Ubuntu や Proxmox VE など

選択肢について

省スペースでコスパが良いとなると、中国メーカー製のミニ PC や、Lenovo の ThinkCentre シリーズが思い浮かぶ。 実際、以前に Lenovo の ThinkCentre シリーズは M75q Tiny Gen2 を購入したことがある。 とはいえ、購入したことのある機材をただ増やすだけというのもあんまり面白くない。

blog.amedama.jp

そんな折、ASRock DeskMini という省スペースなコンピュータを組むためのベアボーンキットのシリーズがあることを知った。 ベアボーンキットなので、ケース、マザーボード、電源、そして CPU クーラーがあらかじめセットになっている。 ちなみに、筐体のサイズや電源の容量が一回り大きな DeskMeet というシリーズもあるようだ。

ASRock DeskMini は CPU のソケットや使用するチップセットごとに、いくつかのモデルが用意されている。 その中でも DeskMini X600 は TDP が 65W までの Socket AM5 の AMD CPU に対応したモデルになる。 今だと発売時よりも値段が下がっていて 3 万円を切る価格で購入できる。

構成を検討する

実際にコンピュータとして動作させるには、ベアボーンキットの他に CPU、メモリ、ストレージが必要になる。 その上でパーツの構成を検討してみると、10 万円前後の予算で組めそうなことが分かった。 なお、パーツの価格は購入するタイミングで変動する。

パーツ 製品 価格 (概算)
ベアボーンキット ASRock DeskMini X600 ¥26k
CPU AMD Ryzen 5 8600G ¥34k
メモリ Crucial DDR5-5600 32GBx2 (SO-DIMM) ¥24k
SSD Crucial T500 1TB ¥12k
CPUクーラー Scythe SHURIKEN 3 ¥4k

これなら、他の選択肢を選んだときに想定されるスペックと価格に対してさほど見劣りしない。 そして、ベアボーンキットならお手軽に自作の楽しさも味わえる点も魅力に映った。

なお、予算を下げるとしたら次のような選択肢がありそう。

  • CPU を下位モデルの Ryzen 5 8500G にする (¥-10k)
  • メモリの容量を半分の 32GB に減らす (¥-10k)
  • ベアボーンキットに付属する CPU クーラーを使用する (¥-4k)

下位モデルの CPU の Ryzen 5 8500G は、8600G と同じ 6 コア 12 スレッドながら 4 コア分が高効率コアの Zen5c になっている。 ただ、TDP はどちらも 65W と変わらないようなので上位モデルを選んだ。 ちなみに、Passmark のベンチマーク (マルチコア) で比較すると 8600G が 25338 で 8500G が 21666 となっていた。

なお、CPU としては 8000 番台と同様に Socket AM5 を採用している 7000 番台や 9000 番台も使用できる。 DeskMini X600 は dGPU の追加が難しいことから iGPU が強力な APU の 8000 番台を組み合わせることが多いようだ。 今回に関してはグラフィックス性能は不要なので [79]000 番台の CPU も選定の候補には挙がった。 ただ、選定のタイミングではコストパフォーマンスに優れたモデルが見当たらなかったため選択しなかった。 また、[79]000 番台を選ぶ場合の注意点としては、以下が挙げられる。

  • TDP が 65W のモデルを選択すること
  • 8000 番台に比べてアイドル電力が 10W ほど大きいこと

CPU クーラーに関しては、ベアボーンキットに付属する CPU クーラーは動作音が大きいというレビューが多いようだった。 そのため、評判の良さそうな Scythe SHURIKEN 3 にしている。 なお、下調べでは Noctua NH-L9a-AM5 を使っている人も多いようだった。 ただし、メーカーの互換性ページを見ると 8600G はベースクロックでの動作時に冷やしきれない (cannot handle) とある。

ncc.noctua.at

公式に互換性が確認されているのは 8500G までのようだった。 発熱は消費電力と相関するはずなので、TDP は同じでも高効率コアを採用している点で両者は特性が異なるのかもしれない。

購入後について

実際に、試算に基づいてひととおりのパーツを購入した。

購入したパーツたち

上記を組み立てていく。

まずは DeskMini X600 の背面のネジを外すと、背面のプレートごとマザーボードが引き出せる。

背面のネジを外してマザーボードを引き出す

CPU を取り付ける

CPU を取り付ける。 取り付け方は一般的な Socket AM5 と変わらない。

  1. レバーを押し下げてクリップから外す
  2. ロードプレートを持ち上げて開く
  3. CPU とソケットの角にある三角形のマークを合わせて CPU をソケットに載せる
  4. ロードプレートを閉じる
  5. レバーを押し下げてクリップに固定する
  6. ソケットを保護しているキャップを取り除く

CPU を取り付ける

なお、この後に塗布する CPU グリスのはみ出しを防ぎたいときや冷却性能を上げたいときはサーマルペーストガードやガードプレートと呼ばれる部品を使うと良い。

CPU クーラーを取り付ける

CPU を取り付けたら、次に CPU クーラーを取り付ける。 DeskMini X600 に付属している CPU クーラーは、マウントホールに取り付けられた既存のブラケットに引っかけるタイプらしい。 一方で、今回選定した Scythe SHURIKEN 3 はソケットごとに専用のマウンティングプレートを使用する。 そのため、マザーボードに取り付けられているブラケットは取り外す。

マウントホールのブラケットを取り外す

取り外したら、CPU クーラーを取り付ける。

  1. 付属する Socket AM5 用のマウンティングプレートを CPU クーラーに装着する
  2. CPU クーラーに付属するサーマルペーストを CPU の表面に塗布する
  3. 付属するネジで CPU クーラーをマザーボードに固定する

CPU クーラーを取り付ける

メモリを取り付ける

次にメモリを取り付ける。 特に気にするようなことはない。

  1. スロットの固定用のクリップを左右に広げる
  2. 切り欠きの位置を揃えて、メモリのモジュールを上からスロットへ押し込む
  3. 固定用のクリップが自動的に閉じる

メモリを取り付ける

SSD を取り付ける

次に SSD を取り付ける。 今回はヒートシンクが付属しないモデルを購入した。 そのため、別途購入したヒートシンクを取り付けた。

サーマルパッドとヒートシンクで SSD をサンドイッチにする。

SSD にヒートシンクを取り付ける

あとは M.2 スロットに差し込んで、DeskMini X600 に付属するネジでマザーボードに固定するだけ。

なお、M.2 スロットは 2 つある。 また、2.5 インチの SATA HDD/SSD も 2 つまで搭載できる。

起動する

これで必要なパーツはすべて組み込めた。 AC アダプタの電源をつないで起動する。 UEFI BIOS が立ち上がって、組み込んだパーツを認識していることを確かめる。

UEFI BIOS が起動してパーツを認識していることを確かめる

メモリをテストする

メモリの初期不良がないか memtest86+ などを使って確認する。 やり方については以下のエントリに書いた。

blog.amedama.jp

OS をインストールする

ひとまず、Ubuntu 24.04 LTS をインストールして使うことにした。 USB メモリに Ubuntu 24.04 LTS のインストーライメージを書き込む。

書き込んだ USB メモリを筐体の USB ポートに差し込んで電源を入れたらインストーラが起動する。 あとはウィザードに従ってインストールするだけ。

まとめ

今回は ASRock DeskMini X600 というベアボーンキットを使って、小型の Linux マシンを組んだことについて書いた。 今のところ、特に問題なく使えている。

後日、筐体の側面にあるベンチレーションホールには以下のマグネット式のダストフィルターを貼り付けた。 140mm ファン用のフィルターでサイズがちょうど良い。 給排気の効率は少し落ちるかもしれないけど掃除の手間を優先した。

また、普段は電源を落として、使うときだけ WoL などで電源を入れるスタイルにしている。 詳しくは以下に書いた。

blog.amedama.jp

いじょう。

Ubuntu 24.04 LTS のマシンを Wake-on-LAN (WoL) でリモートから起動する

昨今の電気代などを考えると、コンピュータは使うとき以外はシャットダウンしておきたい。 アイドル時の電力が数十ワットでも、長く付けっぱなしだと年間で数千円にはなる。 ただ、出先で使いたくなったときなど物理的に電源を入れることが難しいシチュエーションもある。 もちろんサーバ向けの筐体であれば IPMI などの遠隔管理用のインターフェイスがあらかじめ用意されているけど、コンシューマ向けではそれも難しい。 後付けで遠隔管理用のカードを買い足すのにもお金がかかる。 そういったシチュエーションでは手っ取り早く Wake-on-LAN (以下、WoL) の利用が考えられる。 WoL を使うと、同一のネットワークにつなげることさえできれば遠隔からマシンの電源を入れることができる。

WoL はマジックパケットと呼ばれるデータに NIC (Network Interface Card) が反応してマシンの電源を入れてくれる仕組みのこと。 昨今は多くの NIC が WoL に対応している。 なお、前述した通り WoL を利用するには同一ネットワーク上にマジックパケットを送ることのできるマシンが必要になる。 それにはなるべく低消費電力な筐体で VPN や SSH サーバを建てたり、あるいは TailScale などの外部サービスを利用することが考えられる 1

使った環境は次のとおり。 WoL で起動するマシンが Ubuntu 24.04 LTS になる。 使った筐体は ASRock DeskMini X600 のオンボード LAN で、チップは Realtek 社製の RTL8125BG とのこと。

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=24.04
DISTRIB_CODENAME=noble
DISTRIB_DESCRIPTION="Ubuntu 24.04.2 LTS"
$ uname -srm
Linux 6.8.0-55-generic x86_64

そして WoL のマジックパケットを送るのには macOS のマシンを使った。

$ sw_vers         
ProductName:        macOS
ProductVersion:     15.3.2
BuildVersion:       24D81
$ wakeonlan --version        
wakeonlan 0.42

もくじ

下準備

WoL で起動されるマシンの側では、まず UEFI BIOS の設定で WoL を有効にする。 UEFI BIOS での設定方法は、マザーボードを製造しているメーカーやバージョンによって異なる。 参考までに、今回使用した ASRock のマザーボードであれば Advanced Mode に切り替えた上で Advanced タブから PCIE Devices Power On の項目を Enabled にすれば良い。

次に、Ubuntu 上では iproute2 と ethtool をインストールしておく。

$ sudo apt-get install iproute2 ethtool

そして、WoL を使う NIC の MAC アドレスを確認する。 MAC アドレスの確認には ip link show コマンドを使う。

$ ip link show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: enp3s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000
    link/ether 9c:6b:00:XX:XX:XX brd ff:ff:ff:ff:ff:ff

今回であれば上記 enp3s0 インターフェイスの MAC アドレス (9c:6b:00 から始まっている) を見る。 なお、上記で MAC アドレスの下位 24bit はマスクしている。

そして、マジックパケットを送って起動させる側の macOS では Homebrew で wakeonlan をインストールする。 Homebrew が入っていなければインストールする。

$ brew install wakeonlan

一度だけ WoL を有効にする

定常的に使うことは無いだろうけど、まずは動作確認のために ethtool を使ってシャットダウン後に一度だけ WoL を有効にしてみよう。

現状で WoL が有効かは ethtool にネットワークインターフェースの名前を指定することで確認できる。 以下のように Wake-on の値が d であれば WoL が無効になっている。

$ sudo ethtool enp3s0 | grep -i wake-on
    Supports Wake-on: pumbg
    Wake-on: d

一度だけ WoL を有効にするには、次のように -s オプション付きでネットワークインターフェースに wol g を指定する。 値に g を指定することで NIC がマジックパケットに反応するようになる。

$ sudo ethtool -s enp3s0 wol g

もう一度 ethtool を使って Wake-on の値が g になっていることを確認しよう。

$ sudo ethtool enp3s0 | grep -i wake-on
    Supports Wake-on: pumbg
    Wake-on: g

確認したらマシンの電源をシャットダウンする。

$ sudo shutdown -h now

シャットダウンできたら、マジックパケットを送る側のマシンに操作を移す。 wakeonlan コマンドに、先ほど確認した MAC アドレスを指定して実行しよう。

$ wakeonlan 9c:6b:00:XX:XX:XX
Sending magic packet to 255.255.255.255:9 with payload 9c:6b:00:XX:XX:XX
Hardware addresses: <total=1, valid=1, invalid=0>
Magic packets: <sent=1>

上手くいけばマジックパケットに反応してマシンが起動してくるはず。 ただし、先ほど ethtool を使って設定するやり方では永続化されないため、起動後は Wake-on の値が d に戻っている。

$ sudo ethtool enp3s0 | grep -i wake-on
    Supports Wake-on: pumbg
    Wake-on: d

Netplan で設定を永続化する

動作の確認ができたので、次は設定を永続化する。 Ubuntu 22.04 LTS 以降の Ubuntu はネットワークの管理にデフォルトで Netplan を使う。 そして、WoL の設定も Netplan で永続化できる。 Netplan で WoL を有効にするには、ネットワークインターフェースに wakeonlan: true という設定を入れれば良い。

今回使っているマシンでは /etc/netplan/01-fixed-ip-addr.yaml の設定ファイルでインターフェイスの設定をしている。 設定ファイルの名前は環境によって異なるので適宜読み替えてほしい。

初期の状態では、以下のように enp3s0 に DHCPv4 / DHCPv6 の設定を入れつつ静的に IP アドレスを振っている。

$ sudo cat /etc/netplan/01-fixed-ip-addr.yaml 
network:
    ethernets:
      enp3s0:
        dhcp4: true
        addresses:
          - 172.16.XXX.XXX/16
        dhcp4-overrides:
          use-routes: true
          use-dns: true
        dhcp6: true
    version: 2

上記に追加で wakeonlan: true の設定を入れる。

$ sudo cat /etc/netplan/01-fixed-ip-addr.yaml 
network:
    ethernets:
      enp3s0:
        dhcp4: true
        addresses:
          - 172.16.XXX.XXX/16
        dhcp4-overrides:
          use-routes: true
          use-dns: true
        dhcp6: true
        wakeonlan: true
    version: 2

この状態でマシンを再起動してみよう。

$ sudo shutdown -r now

すると、再起動した後でもネットワークインターフェイスに WoL が有効になっている。

$ sudo ethtool enp3s0 | grep -i wake-on
    Supports Wake-on: pumbg
    Wake-on: g

もちろんシャットダウンした上で WoL を使うこともできる。

$ sudo shutdown -h now

マジックパケットを送ってマシンが起動してくることを確認しよう。

$ wakeonlan 9c:6b:00:XX:XX:XX
Sending magic packet to 255.255.255.255:9 with payload 9c:6b:00:XX:XX:XX
Hardware addresses: <total=1, valid=1, invalid=0>
Magic packets: <sent=1>

ばっちりだ。

$ sudo ethtool enp3s0 | grep -i wake-on
    Supports Wake-on: pumbg
    Wake-on: g

めでたしめでたし。


  1. もし自身で適切に設定するのに自信がないときは TailScale などの実績が豊富な外部サービスを利用する方がセキュリティ的にはおすすめできる

Python: multiprocessing モジュールの開始方式 spawn / fork の違いについて

Python の標準ライブラリに含まれる multiprocessing モジュールは、マルチプロセスでの並列処理に用いられる。 Pure Python の処理を並列化しようとしたとき、今のところ最初に検討するのがマルチプロセスになるはず。 というのも、Python のマルチスレッドには多くの場合に GIL (Global Interpreter Lock) の制約があるため。 最も一般的に用いられている Python 処理系の CPython は、まだデフォルトで同時に実行できるスレッドがプロセスあたり一つに限られる 1。 つまり、マルチスレッドでは I/O バウンドな処理を高速化できても、CPU バウンドな処理を高速化できない。 そこで、マルチコア CPU の恩恵を得るためにはスレッドではなくプロセスをたくさん横に並べようという発想になる。 ただし、プロセスはスレッドに比べると生成のコストやプロセス間通信に費やすオーバーヘッドが相対的に大きいといったデメリットがある。

前置きが長くなったけど、今回の本題は multiprocessing モジュールの開始方式 (start method) の違いについて。 開始方式というのは、multiprocessing モジュールが新しくプロセスを作成するときのやり方を指している。 現状で以下の 3 つの方式が用意されていて、プラットフォームごとにデフォルトの開始方式が異なる。

  • spawn
    • Windows と macOS のデフォルト
  • fork
    • macOS を除く POSIX システムのデフォルト
  • forkserver

今回は、メジャーな OS でデフォルトになっている spawn と fork について、どのような違いがあるのか調べたので備忘録として書いておく。 どのように調べたかというと CPython の multiprocessing モジュールのソースコードを読んだ。

使った環境は次のとおり。 macOS の環境は以下を使った。

$ sw_vers
ProductName:        macOS
ProductVersion:     15.3.1
BuildVersion:       24D70
$ uname -srm                    
Darwin 24.3.0 arm64
$ python3 -V
Python 3.12.9

Linux の環境は以下を使った。

$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 24.04.2 LTS
Release:    24.04
Codename:   noble
$ uname -srm
Linux 6.8.0-53-generic aarch64
$ python3 -V
Python 3.12.3

もくじ

基本的な使い方について

開始方式の説明に入る前に、おさらいとして multiprocessing モジュールの基本的な使い方について見ておく。

以下のサンプルコードでは、multiprocessing モジュールで作成した子プロセスが、親プロセスから文字列を受け取って標準出力に書き出す。

import multiprocessing as mp


def f(message):
    """メッセージを受け取って標準出力に書き出す関数"""
    print("Hello,", message)


def main():
    # 子プロセスで実行する処理を定義する
    p = mp.Process(target=f, args=("World!",))
    # 子プロセスを開始する
    p.start()
    # 子プロセスの終了を待つ
    p.join()

if __name__ == "__main__":
    main()

上記を適当なファイル名で保存して実行してみよう。

$ python3 greet.py
Hello, World!

ちゃんとメッセージが表示された。

プロセスの情報を書き出してみる

とはいえ、先ほどの例だとマルチプロセスになっているのかさえよく分からない。 そこで、次はそれぞれのプロセスの情報を書き出すようにしてみよう。

以下のサンプルコードでは、それぞれのプロセスや特殊変数の内容を書き出している。

import os
import multiprocessing as mp


def print_proc_info(context):
    """プロセスに関する情報を標準出力に書き出す関数"""
    # 呼び出し元の情報を出力する
    print(context)
    # __name__ 変数の内容を出力する
    print("__name__:", __name__)
    # 親プロセスの pid を出力する
    print("parent process id:", os.getppid())
    # 自身の pid を出力する
    print("process id:", os.getpid())
    # 見えやすいようにブランクを入れる
    print("")


def f():
    """子プロセスで実行する関数"""
    # プロセスに関する情報を書き出す
    print_proc_info("### Child ###")


def main():
    # 自身のプロセスの情報を出力する
    print_proc_info("### Parent ###")
    # 子プロセスで関数 f() を実行する
    p = mp.Process(target=f)
    p.start()
    p.join()

if __name__ == "__main__":
    main()

上記を実行する。 すると、親と子のプロセスそれぞれの PID や、親プロセスの PID、そして __name__ 変数の内容が出力される。

$ python3 procinfo.py
### Parent ###
__name__: __main__
parent process id: 61809
process id: 82787

### Child ###
__name__: __mp_main__
parent process id: 82787
process id: 82789

上記を見ると、子プロセスの os.getppid() で親プロセスの PID が返されていることが分かる。

また、__name__ の内容も親プロセスと子プロセスでは変わっているようだ。 具体的には子プロセスの場合は __name__ に入っているのが "__mp_main__" になっている。 ただし、これは前述した開始方式に spawn を使った場合の特徴になる。 上記は、デフォルトの開始方式が spawn になっている macOS で実行したもの。 試しにデフォルトの開始方式が fork の Linux (Ubuntu) で実行してみよう。

$ python3 procinfo.py 
### Parent ###
__name__: __main__
parent process id: 1225
process id: 1475

### Child ###
__name__: __main__
parent process id: 1475
process id: 1476

すると、今度は __name__ に入っている内容が、どちらのプロセスも "__main__" になっていることが分かる。

ちなみに、macOS と Linux はそれぞれデフォルトの開始方式が異なっているだけに過ぎない。 つまり、明示的に別の開始方式を指定して使うことはできる。 例えば以下のコードでは set_start_method() 関数を使って明示的に開始方式に fork を指定している。

import multiprocessing as mp
import os


def print_proc_info(context):
    """プロセスに関する情報を標準出力に書き出す関数"""
    print(context)
    print("__name__:", __name__)
    print("parent process id:", os.getppid())
    print("process id:", os.getpid())
    print("")


def f():
    """子プロセスで実行する関数"""
    print_proc_info("### Child ###")


def main():
    # プロセスの開始方式に fork を指定する
    mp.set_start_method("fork")

    print_proc_info("### Parent ###")
    p = mp.Process(target=f)
    p.start()
    p.join()


if __name__ == "__main__":
    main()

上記であれば macOS 上で実行したとしても __name__ に入る内容が親子のプロセスで同じになる。

開始方式の fork について

さて、とうとう本題である開始方式の説明に入る。 まずは、より単純な開始方式の fork について。

開始方式の fork は、実装に文字通り fork というシステムコールを使っている。 システムコールは、ユーザ空間からカーネル空間の機能を利用するために使われる API の一種。 その中で fork というシステムコールは、呼び出し元のプロセスを複製して新しいプロセスを生成するのに使われる。 呼び出し元のプロセスと新しく作られるプロセスは、それぞれ親子関係を持ったプロセスになる。 もし、システムコールや fork といった単語に耳馴染みがないときは以下をおすすめしたい。

以下に、multiprocessing モジュールの開始方式として fork を用いるのと概念的に類似したサンプルコードを示す。 標準ライブラリの os モジュールには fork システムコールを薄くラップした API があることから、それを利用している。

import os
import sys


def print_proc_info(context):
    """プロセスに関する情報を標準出力に書き出す関数"""
    # 呼び出し元の情報を出力する
    print(context)
    # __name__ 変数の内容を出力する
    print("__name__:", __name__)
    # 親プロセスの pid を出力する
    print("parent process id:", os.getppid())
    # 自身の pid を出力する
    print("process id:", os.getpid())
    # 見えやすいようにブランクを入れる
    print("")


def main():
    # プロセスを fork して親子プロセスに分かれる
    pid = os.fork()

    # 返り値で親と子それぞれの処理に分岐できる
    if pid == 0:
        # 子プロセスで実行される処理
        print_proc_info("### Child ###")
        # プロセスを終了する
        sys.exit(0)
    else:
        # 親プロセスで実行される処理
        print_proc_info("### Parent ###")
        # 子プロセスが終了するのを待つ
        os.waitpid(pid, 0)


if __name__ == "__main__":
    main()

上記のコードがどのように動くのか、最初は少しイメージしにくいかもしれないので少し補足する。 上記の os.fork() を呼んだ時点で、同じ行を実行しているプロセスが 2 つできる。 ただし、それぞれのプロセスで os.fork() の返り値が異なっている。 まず、呼び出し元となった親プロセスは返り値として新たに生成された子プロセスの PID が返される。 一方で、新たに生成された子プロセスではゼロが返される。

それぞれのプロセスはその後にある if の条件分岐にそれぞれ入って、返り値ごとに異なる処理を実行する。 親プロセスに関してはプロセスの情報を出力した後で、子プロセスが終了するまで os.waitpid() 関数がブロックする。 そして子プロセスに関してはプロセスの情報を出力した後で os.exit() 関数を使ってプロセスを終了する。 子プロセスが終了すると、親プロセスの os.waitpid() のブロックが解除されるので親プロセスも終了する。

さて、それでは上記のサンプルコードを適当なファイル名で保存して実行してみよう。

$ python3 fork.py
### Parent ###
__name__: __main__
parent process id: 61809
process id: 89320

### Child ###
__name__: __main__
parent process id: 89320
process id: 89321

先ほど multiprocessing モジュールを使ったサンプルコードを Linux 上で実行したのと同じ結果が得られることが分かる。

このように、開始方式の fork は比較的シンプルな作りをしている。 理解する上で重要なポイントは、子のプロセスはメモリなどの状態を親のプロセスから複製しているところ。 そのため親子のプロセスは、特に何もしなくても fork した時点で存在する変数などは同じものが使用できる。 これはメリットとして働く場合もある一方で、誤って同じ資源を異なるプロセスから利用してしまうことで何らかの競合が生じる恐れもある。

開始方式の spawn について

それでは、次にもう少し複雑な処理をしている開始方式の spawn について。

端的に言うと開始方式の spawn は、fork した後で子プロセスが Python のインタプリタを exec し直している (fork-exec)。 その上で、呼び出し元のモジュールを読み込み直したり、実行すべき処理や結果をプロセス間通信でやり取りする。 システムプログラミングの知識があれば、この説明だけでもある程度のイメージが付くかもしれない。 たとえばシェルなどがコマンドを実行するときにやっている処理と大して変わらない。

pipe を使ったプロセス間通信について

本丸となる開始方式の説明に入る前に、少し寄り道して理解の前提として必要な pipe について説明する。 pipe は fork と同様にシステムコールのひとつで、プロセス間通信を実現するための手段のひとつ。 典型的には fork した親子のプロセス間でデータをやり取りするのに使う。 もちろん後ほど spawn に関するサンプルコードの中で使用する。

以下に pipe を fork と組み合わせて使うサンプルコードを示す。 このコードでは親のプロセスから子のプロセスへ pipe を使ってメッセージを送っている。 メッセージを受け取った子のプロセスは、受け取ったメッセージを標準出力に書き出す。

import os


def print_proc_info(context):
    """プロセスに関する情報を標準出力に書き出す関数"""
    print(context)
    print("__name__:", __name__)
    print("parent process id:", os.getppid())
    print("process id:", os.getpid())
    print("")


def main():
    # プロセス間通信に使うパイプを用意する
    child_r, parent_w = os.pipe()

    pid = os.fork()

    if pid == 0:
        # 子プロセスで実行される処理
        print_proc_info("### Child ###")
        # 使用しないパイプは閉じる
        os.close(parent_w)
        # パイプからデータを読み出す
        with os.fdopen(child_r, "r") as pipe_r:
            # 読み出した内容を出力する
            message = pipe_r.read()
            print("Received message:", message)
    else:
        # 親プロセスで実行される処理
        print_proc_info("### Parent ###")
        # 使用しないパイプは閉じる
        os.close(child_r)
        # パイプにデータを書き込む
        with os.fdopen(parent_w, "w") as pipe_w:
            pipe_w.write("Hello, World!")
            pipe_w.flush()
        # 子プロセスの終了を待つ
        os.waitpid(pid, 0)


if __name__ == "__main__":
    main()

上記に適当な名前をつけて実行してみよう。

$ python3 pipe.py 
### Parent ###
__name__: __main__
parent process id: 61809
process id: 96303

### Child ###
__name__: __main__
parent process id: 96303
process id: 96304

Received message: Hello, World!

上記の出力から、親プロセスのメッセージを子プロセスで受信できていることが確認できる。

開始方式の spawn と概念的に類似したコード

さて、それでは本丸となるサンプルコードを示す。 これは multiprocessing モジュールの開始方式として spawn を用いる場合にやっていることと概念的にほぼ変わらない。

import os
import pickle
import sys


def print_proc_info(context):
    """プロセスに関する情報を標準出力に書き出す関数"""
    print(context)
    print("__name__:", __name__)
    print("parent process id:", os.getppid())
    print("process id:", os.getpid())
    print("")


def f(message):
    """メッセージを受け取って標準出力に書き出す関数"""
    print("Hello,", message)


def spawn_child_process(target, args):
    """multiprocessing の spawn モードと類似した子プロセスの生成を実現する関数"""
    # 親子プロセス間の通信に使うパイプを用意する
    child_r, parent_w = os.pipe()

    # プロセスを fork する
    pid = os.fork()

    if pid == 0:
        # 子プロセスで実行される処理
        # 使用しない方のパイプは閉じる
        os.close(parent_w)
        # exec 後の子プロセスでパイプのファイルディスクリプタを利用できるようにする
        os.set_inheritable(child_r, True)
        # 子プロセスは fork した直後に exec で実行するプログラムを書き換える
        # 書き換え先になるプログラムは Python インタプリタ
        # このとき __file__ を引数にして、起動するインタプリタに読み込ませる
        # つまり新しい Python のプロセスでモジュールを実行し直すのに相当する
        os.execvp(
            sys.executable,
            [
                sys.executable,
                # このモジュール (.py) を新しく生成したプロセスに読ませるための引数
                __file__,
                # fork & exec した子プロセスであることを識別するための引数をつけておく
                "--multiprocessing-fork",
                # プロセス間通信に使うパイプのファイルディスクリプタの情報も引数で渡す
                str(child_r),
            ],
        )
    else:
        # 親プロセスで実行される処理
        # 使用しない方のパイプは閉じる
        os.close(child_r)
        # 子プロセスで呼び出したい関数と引数を Pickle でシリアライズする
        pickled_data = pickle.dumps((target, args))
        # シリアライズしたバイト列をパイプに書き込む
        with os.fdopen(parent_w, "wb") as pipe_w:
            pipe_w.write(pickled_data)
            pipe_w.flush()
        # 親プロセスは子プロセスが終了するのを待つ
        os.waitpid(pid, 0)


def is_spawned_child_process():
    """fork & exec した後の子プロセスかを判断する関数"""
    return len(sys.argv) > 1 and sys.argv[1] == "--multiprocessing-fork"


def main():
    if is_spawned_child_process():
        # 子プロセスで実行される処理
        print_proc_info("### Child ###")
        # 親プロセスから引き継いだファイルディスクリプタの情報を引数から得る
        child_r = int(sys.argv[2])
        # パイプのファイルディスクリプタを開く
        with os.fdopen(child_r, "rb") as pipe_r:
            # 親プロセスから送られてくる実行すべき関数と引数の情報をデシリアライズする
            message = pipe_r.read()
            target, args = pickle.loads(message)
            # デシリアライズした関数を実行する
            target(args)
    else:
        print_proc_info("### Parent ###")
        # multiprocessing の spawn モードに類似したやり方で関数を実行する
        spawn_child_process(f, "World!")


if __name__ == "__main__":
    main()

上記には、いくつか理解する上で重要なポイントがある。

まず、子プロセスは fork した後に os.execvp() という関数を使っている。 この関数は exec と呼ばれるシステムコールの薄いラッパになっている。 exec システムコールを使うと、現在のプロセスで異なるプログラムをメモリ上にロードして新たに実行できる。 実行しているのは sys.executable なので、要するに呼び出し元の親プロセスで実行していたのと同じ Python 処理系になる。

そして、Python 処理系の引数には __file__ を渡していることから、元々のプロセスが実行していたのと同じモジュールが読み込まれる。 これによって、親のプロセスと同じ関数や変数などが読み込まれて使えるようになる。 ただし、新しいプロセスでモジュールを読み込み直しているので親プロセスとオブジェクト自体は異なっている。

さらに、親プロセスと子プロセスの間でやり取りが必要な情報は、前述した pipe を使って通信している。 具体的には、子プロセスで実行してほしい呼び出し可能オブジェクトや、その引数を Pickle でシリアライズして親プロセスから送っている。 なお、今回は単純のために子プロセスから親プロセスの方向で戻すはずの返り値やエラーなどに関する情報は省いている。

それでは、実際にコードに適当な名前をつけて実行してみよう。

$ python3 spawn.py 
### Parent ###
__name__: __main__
parent process id: 61809
process id: 98614

### Child ###
__name__: __main__
parent process id: 98614
process id: 98615

Hello, World!

上記から、ちゃんと子プロセスで処理が実行されたことが分かる。

ちなみに開始方式に spawn を使った multiprocessing のコードは、REPL で対話的に実行すると動作しない。 これは、上記のサンプルコードでやっていることを確認すれば自明で、REPL では __file__ が存在しないから。 要するに、新しく起動した Python プロセスにモジュールを読み込ませて関数などのオブジェクトの状態を復元できないことが原因のようだ。

また、開始方式に spawn を使う場合、子プロセスでは実行したくない処理を if __name__ == "__main__": のインデントの中に置く必要がある。 これについても、新しく起動した Python プロセスにモジュールを読み込ませることが理由になる。 もしインデントの外に出ていると、起動した Python プロセスにモジュールを読み込ませるタイミングでそのコードが評価されてしまう。 前述した通り、開始方式が spawn だと子プロセスの __name__"__mp_main__" になる。 そのため、子プロセスでは条件分岐のインデント内の処理が実行されなくなる。

まとめ

今回は multiprocessing モジュールの開始方式である spawn と fork がやっていることについて調べた。

まず開始方式の fork は、単純に fork(2) しているだけ。 シンプルでオーバーヘッドが少ない点がメリットになる。 その反面、プロセス間で共有するものが多いことから競合が生じやすい。

そして開始方式の spawn は、fork(2) した上で exec(2) している。 これには exec(2) したり、その後でモジュールの読み込みやプロセス間通信に費やすオーバーヘッドが大きいデメリットがある。 その反面、Python のプロセスを一から作り直していることからプロセス間で共有するものが少なくて競合が生じにくい。

なお、今回は第三の開始方式である forkserver について扱わなかった。 どうやら forkserver は子プロセスを生成するために専用のプロセスを用意して、UNIX ドメインソケットでやり取りする実装になっているようだ。 機会があれば、また改めて紹介したい。

ちなみに、一般的な処理の並列化を目的とする場合には multiprocessing モジュールよりも concurrent.features モジュールを使うのがおすすめ。 concurrent.features モジュールの ProcessPoolExecutor の方が multiprocessing モジュールよりも高レベルな API なので楽に並列化できる。

docs.python.org

参考

CPython の multiprocessing モジュールに関するソースコードは以下で読める。

github.com


  1. デフォルトで GIL の制約を外そうという長期的な取り組みはある (https://peps.python.org/pep-0703/)

macOS で memtest86+ の iso ファイルを USB メモリに書き込む

コンピュータを購入した直後にやることのひとつといえば、メモリに初期不良がないかを調べること。 ごく稀なことではあるけど購入した時点で故障していることがある。 初期不良は交換の対象になることから、なるべく早い段階で確認する方が良い。 あるいは、もちろん使い続けて経年で故障することもある。

memtest86+ 1 はメモリの故障を見つけるためのソフトウェアのひとつ。 様々なパターンでデータの書き込みと読み込みを繰り返すことで、メモリのデータが化けないかを確認する。 起動ディスクとして USB メモリなどに書き込んで使う。

使った環境は以下のとおり。

$ sw_vers                                                            
ProductName:        macOS
ProductVersion:     15.3.1
BuildVersion:       24D70

もくじ

memtest86+ をダウンロードする

まずは memtest86+ の ISO ファイルをダウンロードする。 使用するバージョンやダウンロード用の URL は公式サイトで確認しよう。

$ brew install wget
$ wget https://www.memtest.org/download/v7.20/mt86plus_7.20_64.iso.zip

圧縮されているので展開する。

$ unzip mt86plus_7.20_64.iso.zip 
Archive:  mt86plus_7.20_64.iso.zip
  inflating: memtest.iso

次のように ISO ファイルが得られる。

$ ls -1         
memtest.iso
mt86plus_7.20_64.iso.zip
$ file memtest.iso
memtest.iso: ISO 9660 CD-ROM filesystem data (DOS/MBR boot sector) 'MT86PLUS_64' (bootable)

ファイルを USB メモリに書き込む

次に、上記を USB メモリなどのメディアに書き込む。 使用する USB メモリは、特にこだわりが無ければ何を使っても構わない。

USB メモリを Mac のポートに差し込む前に、macOS が認識しているディスクの状態を確認しておく。 これには diskutil コマンドを使うと良い。

$ diskutil list
/dev/disk0 (internal, physical):
   #:                       TYPE NAME                    SIZE       IDENTIFIER
   0:      GUID_partition_scheme                        *500.3 GB   disk0
   1:             Apple_APFS_ISC Container disk1         524.3 MB   disk0s1
   2:                 Apple_APFS Container disk3         494.4 GB   disk0s2
   3:        Apple_APFS_Recovery Container disk2         5.4 GB     disk0s3

/dev/disk3 (synthesized):
   #:                       TYPE NAME                    SIZE       IDENTIFIER
   0:      APFS Container Scheme -                      +494.4 GB   disk3
                                 Physical Store disk0s2
   1:                APFS Volume Macintosh HD            11.2 GB    disk3s1
   2:              APFS Snapshot com.apple.os.update-... 11.2 GB    disk3s1s1
   3:                APFS Volume Preboot                 7.0 GB     disk3s2
   4:                APFS Volume Recovery                1.0 GB     disk3s3
   5:                APFS Volume Data                    77.9 GB    disk3s5
   6:                APFS Volume VM                      20.5 KB    disk3s6

Mac の USB ポートに USB メモリを差し込むと /dev 以下のディスクとして認識するはず。 今回は /dev/disk4 として認識した。

$ diskutil list
/dev/disk0 (internal, physical):
   #:                       TYPE NAME                    SIZE       IDENTIFIER
   0:      GUID_partition_scheme                        *500.3 GB   disk0
   1:             Apple_APFS_ISC Container disk1         524.3 MB   disk0s1
   2:                 Apple_APFS Container disk3         494.4 GB   disk0s2
   3:        Apple_APFS_Recovery Container disk2         5.4 GB     disk0s3

/dev/disk3 (synthesized):
   #:                       TYPE NAME                    SIZE       IDENTIFIER
   0:      APFS Container Scheme -                      +494.4 GB   disk3
                                 Physical Store disk0s2
   1:                APFS Volume Macintosh HD            11.2 GB    disk3s1
   2:              APFS Snapshot com.apple.os.update-... 11.2 GB    disk3s1s1
   3:                APFS Volume Preboot                 7.0 GB     disk3s2
   4:                APFS Volume Recovery                1.0 GB     disk3s3
   5:                APFS Volume Data                    77.9 GB    disk3s5
   6:                APFS Volume VM                      20.5 KB    disk3s6

/dev/disk4 (external, physical):
   #:                       TYPE NAME                    SIZE       IDENTIFIER
   0:     FDisk_partition_scheme                        *60.5 GB    disk4
   1:             Windows_FAT_32 NO NAME                 60.5 GB    disk4s1

一般的には、購入した直後にそのまま使用できるように FAT32 などの形式でフォーマットされている。 その場合、ポートに USB メモリを差し込むと自動でマウントされる。 あらかじめアンマウントしておく。

$ diskutil unmountDisk /dev/disk4
Unmount of all volumes on disk4 was successful

アンマウントした後にdf コマンドなどでディスクが表示されないことを確認する。

$ df -h | grep disk4

あとはダウンロードしてきた ISO ファイルを dd コマンドを使って認識したデバイスへ書き込む。

$ sudo dd if=memtest.iso of=/dev/disk4 bs=1m

書き込んだ USB メモリを使用する

正常に書き込みが終わったら、あとはその USB メモリを使うだけ。 メモリをテストしたいコンピュータの筐体の USB ポートに USB メモリを差し込んで起動する。 もし memtest86+ が起動しないときは、ディスクの起動順序やセーフブートの設定を確認する。

memtest86+ を実行する

一晩くらい放置してテストが何度かパスすることを確認すれば、ひとまず問題ない。 エラーが出るときは故障している可能性が高い。

いじょう。

Python: 複数行の文字列のインデントを揃えて読みやすくしたい

今回は小ネタ。 インデントのあるソースコード上で、複数行の文字列を読みやすくする方法について。 毎回どうやってたっけと調べるのでメモしておく。

使った環境は次のとおり。

$ sw_vers
ProductName:        macOS
ProductVersion:     15.2
BuildVersion:       24C101
$ python -V         
Python 3.11.9

もくじ

複数行の文字列のインデントを揃えたくなる場面について

インデントのある場所で複数行の文字列を扱おうとすると、以下のような感じになりがち。 ここでは関数 f() の中で "a""b""c" という文字を、それぞれ独立した行で、先頭にスペースを含めずに出力したい。 設定ファイルとかテンプレートなんかを扱うときのイメージ。

def f():
    s = """a
b
c"""
    print(s)

上記はもちろん上手くいく。 ただ、ソースコード上のインデントと文字列がズレているのでちょっと読みにくく感じる。

>>> f()
a
b
c

理想としては、たとえば以下のような感じにしたい。

def f():
    s = """
    a
    b
    c
    """
    print(s)

ただ、これだと 2 つ課題がある。 まず 1 つ目が、各行の先頭にインデントに相当するスペースが入ってしまうこと。 そして 2 つ目が、先頭と末尾に空行が入ってしまうこと。

>>> f()

    a
    b
    c
    

これだと設定ファイルやテンプレートとして利用できないことが考えられる。

各行の先頭のインデントを取り除く

まず、各行の先頭にインデントに相当するスペースが入ってしまう点は textwrap モジュールに dedent() という便利な関数がある。 この関数を使うと各行に入った先頭のインデントを除去してくれる。

import textwrap

def f():
    s = """
    a
    b
    c
    """
    print(textwrap.dedent(s))

使ってみると、次のように先頭のインデントに相当するスペースが取り除かれた。

>>> f()

a
b
c

先頭と末尾の空行を取り除く

次に、先頭と末尾の空行に関しては str#strip() メソッドを使うことで取り除ける。

import textwrap

def f():
    s = """
    a
    b
    c
    """
    print(textwrap.dedent(s).strip())

やってみると、次のように先頭と末尾の空行が取り除かれた。

>>> f()
a
b
c

いじょう。

Python: PyTorch で Adam を実装してみる

今回は、以下の記事の続きとして PyTorch で Adam を実装してみる。

blog.amedama.jp

Adam は、その収束の早さなどから利用されることの多い代表的なオプティマイザのひとつになっている。

使った環境は次のとおり。

$ sw_vers
ProductName:        macOS
ProductVersion:     15.2
BuildVersion:       24C101
$ python -V          
Python 3.12.7
$ pip list | egrep -i "(torch|matplotlib)"
matplotlib        3.9.2
torch             2.5.1

もくじ

下準備

あらかじめ PyTorch と Matplotlib をインストールしておく。

$ pip install torch matplotlib 

PyTorch 組み込みの Adam を試す

まずは PyTorch に組み込みで用意されている Adam の振る舞いを確認する。

以下にサンプルコードを示す。 扱う問題は先に示した記事と同じもの。 問題設定や初期値などは「ゼロから作るDeep Learning1」に記載されている内容と揃えている。 サンプルコードでは、関数の出力をゼロに近づけるようにパラメータを更新していく。

import torch
from matplotlib import pyplot as plt
from torch import nn
from torch import optim


class ExampleFunction(nn.Module):

    def __init__(self, a, x, b, y):
        super(ExampleFunction, self).__init__()
        self.a = a
        self.x = nn.Parameter(torch.tensor([x]))
        self.b = b
        self.y = nn.Parameter(torch.tensor([y]))

    def forward(self):
        return self.a * self.x**2 + self.b * self.y**2


def main():
    model = ExampleFunction(a=1 / 20, x=-7.0, b=1.0, y=2.0)

    optimizer = optim.Adam(model.parameters(), lr=0.3)

    trajectory_x = [model.x.detach().numpy()[0]]
    trajectory_y = [model.y.detach().numpy()[0]]

    num_epochs = 30
    for epoch in range(1, num_epochs + 1):
        optimizer.zero_grad()

        outputs = model()

        outputs.backward()

        optimizer.step()

        x = model.x.detach().numpy()[0]
        trajectory_x.append(x)
        y = model.y.detach().numpy()[0]
        trajectory_y.append(y)

    fig, ax = plt.subplots(1, 1, figsize=(8, 8))
    ax.plot(trajectory_x, trajectory_y, marker="o", markersize=5, label="Trajectory")
    ax.legend()
    ax.grid(True)
    ax.set_xlabel("x")
    ax.set_ylabel("y")
    plt.show()


if __name__ == "__main__":
    main()

上記に適当な名前をつけて保存した上で実行する。

$ python torchadam.py

すると、以下のようなグラフが得られる。 これは、パラメータが更新されてゼロに近づいていく過程を表している。 このパラメータが更新される振る舞いがオプティマイザのアルゴリズムによって異なっている。

PyTorch 組み込みの Adam で最適化したパラメータの軌跡

Adam のオプティマイザを自作する

続いては Adam のオプティマイザを自作してみる。 サンプルコードを以下に示す。 サンプルコードでは CustomAdam という名前でオプティマイザを実装している。

from collections.abc import Iterable
from typing import Any

import torch
from matplotlib import pyplot as plt
from torch import nn
from torch.optim import Optimizer


class ExampleFunction(nn.Module):

    def __init__(self, a, x, b, y):
        super(ExampleFunction, self).__init__()
        self.a = a
        self.x = nn.Parameter(torch.tensor([x]))
        self.b = b
        self.y = nn.Parameter(torch.tensor([y]))

    def forward(self):
        return self.a * self.x**2 + self.b * self.y**2


class CustomAdam(Optimizer):
    """自作した Adam のオプティマイザ"""

    def __init__(
        self,
        params: Iterable,
        lr: float = 1e-3,
        beta1: float = 0.9,
        beta2: float = 0.999,
        eps: float = 1e-8,
    ):
        defaults: dict[str, Any] = dict(
            lr=lr,
            beta1=beta1,
            beta2=beta2,
            eps=eps,
        )
        super(CustomAdam, self).__init__(params, defaults)

    def step(self, closure=None):
        for group in self.param_groups:
            beta1 = group["beta1"]
            beta2 = group["beta2"]
            eps = group["eps"]

            # beta_1 をイテレーション数だけ乗算する変数
            if "beta1_t" not in self.state:
                self.state["beta1_t"] = torch.tensor(1.)
            self.state["beta1_t"] *= beta1

            # beta_2 をイテレーション数だけ乗算する変数
            if "beta2_t" not in self.state:
                self.state["beta2_t"] = torch.tensor(1.)
            self.state["beta2_t"] *= beta2

            for param in group["params"]:
                if param.grad is None:
                    continue
                """
                (更新式)
                m_0 = 0
                v_0 = 0
                g_{t+1} = grad(L(theta_t))
                m_{t+1} = beta1 * m_t + (1 - beta1) * g_{t+1}
                v_{t+1} = beta2 * v_t + (1 - beta2) * g_{t+1}^2
                hat{m_{t+1}} = m_{t+1} / (1 - beta1^t)
                hat{v_{t+1}} = v_{t+1} / (1 - beta2^t)
                theta_{t+1} = theta_t - alpha * (hat{m_{t+1}} / (sqrt{hat{v_{t+1}}} + eps))

                theta: パラメータ (重み)
                alpha: 学習率
                grad(L(theta)): 損失関数の勾配
                m: 指数移動平均で求めた勾配の 1 次モーメント
                v: 指数移動平均で求めた勾配の 2 次モーメント
                beta1: 指数移動平均で勾配の 1 次モーメントを求める際の係数
                beta2: 指数移動平均で勾配の 2 次モーメントを求める際の係数
                eps: ゼロ除算を防ぐ小さな値
                """
                # m_0 = 0 に対応する
                if "m" not in self.state[param]:
                    self.state[param]["m"] = torch.zeros_like(param.data)
                # v_0 = 0 に対応する
                if "v" not in self.state[param]:
                    self.state[param]["v"] = torch.zeros_like(param.data)

                # m_{t+1} = beta1 * m_t + (1 - beta1) * g_{t+1} に対応する
                self.state[param]["m"] = beta1 * self.state[param]["m"] + (1 - beta1) * param.grad
                # v_{t+1} = beta2 * v_t + (1 - beta2) * g_{t+1}^2 に対応する
                self.state[param]["v"] = beta2 * self.state[param]["v"] + (1 - beta2) * param.grad ** 2

                # hat{m_{t+1}} = m_{t+1} / (1 - beta1^t) に対応する
                # beta1^t は self.state["beta1_t"] で計算している
                m_hat = self.state[param]["m"] / (1 - self.state["beta1_t"])
                # hat{v_{t+1}} = v_{t+1} / (1 - beta2^t) に対応する
                # beta2^t は self.state["beta2_t"] で計算している
                v_hat = self.state[param]["v"] / (1 - self.state["beta2_t"])

                # theta_{t+1} = theta_t - alpha * (hat{m_{t+1}} / (sqrt{hat{v_{t+1}}} + eps)) に対応する
                param.data -= group["lr"] * (m_hat / (torch.sqrt(v_hat) + eps))


def main():
    model = ExampleFunction(a=1 / 20, x=-7.0, b=1.0, y=2.0)

    optimizer = CustomAdam(model.parameters(), lr=0.3)

    trajectory_x = [model.x.detach().numpy()[0]]
    trajectory_y = [model.y.detach().numpy()[0]]

    num_epochs = 30
    for epoch in range(1, num_epochs + 1):
        optimizer.zero_grad()

        outputs = model()

        outputs.backward()

        optimizer.step()

        x = model.x.detach().numpy()[0]
        trajectory_x.append(x)
        y = model.y.detach().numpy()[0]
        trajectory_y.append(y)

    fig, ax = plt.subplots(1, 1, figsize=(8, 8))
    ax.plot(trajectory_x, trajectory_y, marker="o", markersize=5, label="Trajectory")
    ax.legend()
    ax.grid(True)
    ax.set_xlabel("x")
    ax.set_ylabel("y")
    plt.show()


if __name__ == "__main__":
    main()

上記に適当な名前をつけて実行する。

$ python customadam.py

すると、次のようなグラフが得られる。

自作した Adam で最適化したパラメータの軌跡

グラフから、PyTorch に組み込みで用意されている Adam と同じ軌跡を辿っていることが確認できる。

更新式とコードの対応関係について

ここからは更新式とコードについて見ていく。

Adam の更新式は以下のようになっている。

 \displaystyle
m_0 = 0 \\
v_0 = 0 \\
g_{t+1} = \nabla_{\theta} L(\theta_t) \\
m_{t+1} = \beta_1 m_t + (1 - \beta_1) g_{t+1} \\
v_{t+1} = \beta_2 v_t + (1 - \beta_2) g_{t+1}^2 \\
\hat{m_{t+1}} = \frac{m_{t+1}}{1 - \beta_1^{t+1}} \\
\hat{v_{t+1}} = \frac{v_{t+1}}{1 - \beta_2^{t+1}} \\
\theta_{t+1} = \theta_t - \alpha \frac{\hat{m_{t+1}}}{\sqrt{\hat{v_{t+1}}} + \epsilon}

数式と、プログラムの変数の対応関係は次のとおり。

  •  \theta
    • param.data
  •  \alpha
    • group["lr"]
  •  \nabla_{\theta} L(\theta)
    • param.grad
  •  \beta_1
    • beta1
  •  \beta_2
    • beta2
  •  m
    • self.state[param]["m"]
  •  v
    • self.state[param]["v"]
  •  \epsilon
    • group["eps"]
  •  t
    • self.state["t"]

式から、 m v が、いずれも勾配を元に指数移動平均を求める形になっていることが分かる。  m についてはそのまま、 v については二乗しているため、それぞれ 1 次モーメントと 2 次モーメントを表しているらしい。 これらの値にイテレーション回数に関するバイアス補正をかけた上で、スケール調整したものを使ってパラメータを更新する。

直感的には、過去の勾配の情報が次の更新に強く影響するように感じられる。 たとえば一度勢いがつくと、なかなかその方向への更新が止まりにくいはず。 この点は勢いよく更新した方が、大局的にはパラメータが早く収束するのかもしれない。 また、汎化性能を得やすいとされる損失関数の平坦解にも到達しやすいのだろう。

ここからはコードとの対応関係を見ていこう。 まずは以下の更新式に対応するコードから。

 \displaystyle
m_0 = 0 \\
v_0 = 0

ここでは  m v がまだ無い状態、つまり初期状態のときに変数をゼロで初期化している。

                # m_0 = 0 に対応する
                if "m" not in self.state[param]:
                    self.state[param]["m"] = torch.zeros_like(param.data)
                # v_0 = 0 に対応する
                if "v" not in self.state[param]:
                    self.state[param]["v"] = torch.zeros_like(param.data)

次に以下の更新式に対応するコード。

 \displaystyle
g_{t+1} = \nabla_{\theta} L(\theta_t) \\
m_{t+1} = \beta_1 m_t + (1 - \beta_1) g_{t+1} \\
v_{t+1} = \beta_2 v_t + (1 - \beta_2) g_{t+1}^2

ここでは指数移動平均で勾配の 1 次モーメントと 2 次モーメントを計算している。

                # m_{t+1} = beta1 * m_t + (1 - beta1) * g_{t+1} に対応する
                self.state[param]["m"] = beta1 * self.state[param]["m"] + (1 - beta1) * param.grad
                # v_{t+1} = beta2 * v_t + (1 - beta2) * g_{t+1}^2 に対応する
                self.state[param]["v"] = beta2 * self.state[param]["v"] + (1 - beta2) * param.grad ** 2

次に以下の更新式に対応するコード。

 \displaystyle
\hat{m_{t+1}} = \frac{m_{t+1}}{1 - \beta_1^{t+1}} \\
\hat{v_{t+1}} = \frac{v_{t+1}}{1 - \beta_2^{t+1}}

ここではバイアス補正をしている。 イテレーション数がゼロのときは各モーメントがゼロから始まるので、そのままでは更新量が少なくなってしまう。 そこでイテレーション回数が少ないうちは更新量を多く、回数が多くなるほど更新量を抑えるように調整している。

                # hat{m_{t+1}} = m_{t+1} / (1 - beta1^t) に対応する
                # beta1 ** t は self.state["beta1_t"] で計算している
                m_hat = self.state[param]["m"] / (1 - self.state["beta1_t"])
                # hat{v_{t+1}} = v_{t+1} / (1 - beta2^t) に対応する
                # beta2 ** t は self.state["beta2_t"] で計算している
                v_hat = self.state[param]["v"] / (1 - self.state["beta2_t"])

なお、 \beta_1^{t+1} \beta_2^{t+1} の部分は以下のように求めている。 これは、イテレーション毎に改めて計算していると無駄な計算が生じるため。

            # beta_1 をイテレーション数だけ乗算する変数
            if "beta1_t" not in self.state:
                self.state["beta1_t"] = torch.tensor(1.)
            self.state["beta1_t"] *= beta1

            # beta_2 をイテレーション数だけ乗算する変数
            if "beta2_t" not in self.state:
                self.state["beta2_t"] = torch.tensor(1.)
            self.state["beta2_t"] *= beta2

そして、最後に以下の更新式に対応するコード。

 \displaystyle
\theta_{t+1} = \theta_t - \alpha \frac{\hat{m_{t+1}}}{\sqrt{\hat{v_{t+1}}} + \epsilon}

ここでは 1 次モーメントを 2 次モーメントの平方根でスケール調整した値でパラメータを更新している。

                # theta_{t+1} = theta_t - alpha * (hat{m_{t+1}} / (sqrt{hat{v_{t+1}}} + eps)) に対応する
                param.data -= group["lr"] * (m_hat / (torch.sqrt(v_hat) + eps))

いじょう。

参考

arxiv.org

arxiv.org

Ubuntu 22.04 LTS に最新の Vagrant / VirtualBox をインストールする

今回は Ubuntu 22.04 LTS に公式のリポジトリから最新の Vagrant と VirtualBox をインストールする方法について。 Ubuntu のデフォルトのリポジトリからでもインストールはできるんだけどバージョンが古い。 また、公式のドキュメントを参照しても、新しめのディストリビューションではそのまま使えない内容が載っていたりする。

使った環境は次のとおり。

$ uname -srm
Linux 6.8.0-49-generic x86_64
$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=22.04
DISTRIB_CODENAME=jammy
DISTRIB_DESCRIPTION="Ubuntu 22.04.5 LTS"

もくじ

VirtualBox をインストールする

まずは VirtualBox のインストールから。

その前に、もし古いバージョンの VirtualBox が入っている場合にはアンインストールしておく。 入っていないときは以下の手順は必要ない。

$ sudo apt-get remove --purge virtualbox*
$ sudo apt-get autoremove --purge
$ sudo apt-get autoclean

リポジトリを登録する上で必要なパッケージをインストールしておく。

$ sudo apt-get update
$ sudo apt-get install -y wget gpg

VirtualBox のリポジトリを APT に登録する。

$ echo "deb [arch=amd64] https://download.virtualbox.org/virtualbox/debian jammy contrib" | sudo tee /etc/apt/sources.list.d/virtualbox.list

リポジトリの署名をシステムに登録する。

$ wget -O- https://www.virtualbox.org/download/oracle_vbox_2016.asc | sudo gpg --yes --dearmor -o /etc/apt/trusted.gpg.d/oracle-virtualbox-2016.gpg

リポジトリの情報を更新してエラーが無いことを確認する。

$ sudo apt-get update

メジャーバージョンとマイナーバージョンを指定して VirtualBox をインストールする。 最新のバージョンを調べるときは公式サイトを確認する。

$ sudo apt-get -y install virtualbox-7.1

インストールが終わったらシステムを再起動する。

$ sudo shutdown -r now

以上で最新の VirtualBox がインストールできる。

$ VBoxManage --version
7.1.4r165100

Vagrant をインストールする

続いては Vagrant をインストールする。

先ほどと同様に、もし古いバージョンが入っているときはアンインストールする。 入っていないときは以下の手順は必要ない。

$ sudo apt-get remove --purge vagrant*
$ sudo apt-get autoremove --purge
$ sudo apt-get autoclean

HashiCorp のリポジトリを APT に登録する。

$ echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list

リポジトリの署名をシステムに登録する。

$ wget -O - https://apt.releases.hashicorp.com/gpg | sudo gpg --yes --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg

リポジトリの情報を更新してエラーがないことを確認する。

$ sudo apt update

そして Vagrant をインストールする。

$ sudo apt install vagrant

動作を確認する

動作確認のために、実際に Vagrant / VirtualBox で仮想マシンを作ってみよう。 ホストとは別のディストリビューションとして Ubuntu 24.04 LTS を用いる。

$ vagrant init bento/ubuntu-24.04

仮想マシンを起動する。

$ vagrant up

できたら仮想マシンに SSH する。

$ vagrant ssh

ちゃんと動いているようだ。

$ uname -srm
Linux 6.8.0-31-generic x86_64
$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=24.04
DISTRIB_CODENAME=noble
DISTRIB_DESCRIPTION="Ubuntu 24.04 LTS"

いじょう。

参考

www.virtualbox.org

developer.hashicorp.com