CUBE SUGAR CONTAINER

技術系のこと書きます。

Linux の Mount Namespace について

Linux のコンテナ仮想化を構成する要素の一つに、カーネルの Namespace (名前空間) という機能がある。 これは、プロセスが動作する際のリソースを Namespace という単位で隔離して扱うための仕組み。 以下のとおり、隔離する対象によって Namespace は色々とある。

man7.org

今回は、その中でもマウント情報を隔離するための Mount Namespace を扱ってみる。 具体的には、unshare(1) と unshare(2) を使ってマウント情報が隔離される様子を観察してみる。 unshare(2) というのは Namespace を操作するためのシステムコールで、unshare(1) は同名のシステムコールを利用したコマンドラインツールになっている。

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

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=20.04
DISTRIB_CODENAME=focal
DISTRIB_DESCRIPTION="Ubuntu 20.04.3 LTS"
$ uname -rm
5.4.0-91-generic aarch64
$ unshare --version
unshare from util-linux 2.34
$ gcc --version
gcc (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0
Copyright (C) 2019 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

もくじ

下準備

下準備として、mount(1) と unshare(1) を使うために util-linux をインストールする。 同様に、unshare(2) を使ったコードをビルドするために build-essential をインストールする。

$ sudo apt-get update
$ sudo apt-get -y install util-linux build-essential

unshare(1) を使って Mount Namespace を操作する

まずは unshare(1) を使って Mount Namespace を試してみよう。 現在のプロセスの Mount Namespace の識別子は procfs から取得できる。 具体的には /proc/self/ns/mnt を読めば良い。

$ file /proc/self/ns/mnt
/proc/self/ns/mnt: symbolic link to mnt:[4026531840]

上記で右端にあるブラケットに囲まれた数字は、現在のプロセス、つまりシェルが所属する Mount Namespace を表している。

今回は /tmp/mntpoint というディレクトリを例にして説明していこう。 このディレクトリに tmpfs をマウントして、マウントされたのが見えるか否かで動作確認していく。 まずはマウント先としてディレクトリを作っておこう。

$ mkdir -p /tmp/mntpoint

作ったディレクトリに動作確認用のファイルを適当に作っておく。

$ echo "Hello, World" > /tmp/mntpoint/greet.txt
$ ls /tmp/mntpoint
greet.txt
$ cat /tmp/mntpoint/greet.txt
Hello, World

続いては肝心の unshare(1) を使って Mount Namespace を作る。 このとき --mount オプションを付与することで Mount Namespace を作ることを指定しよう。 末尾には bash を指定してシェルを起動する。

$ sudo unshare --mount bash

上記を実行したら、先ほどと同じように /proc/self/ns/mnt にアクセスしてみよう。 すると、識別子が変わっていることがわかる。 つまり、先ほどとは異なる Mount Namespace にプロセスが所属している。

# file /proc/self/ns/mnt
/proc/self/ns/mnt: symbolic link to mnt:[4026532128]

この時点では、まだ /tmp/mntpoint 以下には何もしていないのでファイルが見える。

# ls /tmp/mntpoint/
greet.txt

ここでおもむろに tmpfs を上記のディレクトリにマウントしてみよう。

# mount -t tmpfs tmpfs /tmp/mntpoint

すると、当然だけどマウントされてファイルは見えなくなる。

# mount | grep /tmp/mntpoint
tmpfs on /tmp/mntpoint type tmpfs (rw,relatime)
# ls /tmp/mntpoint/| wc -l
0

マウントできたことが確認できたら元のシェルに戻ろう。

# exit

元のシェルに戻ると Mount Namespace も元に戻っていることがわかる。

$ file /proc/self/ns/mnt
/proc/self/ns/mnt: symbolic link to mnt:[4026531840]

確認すると /tmp/mntpoint には何もマウントされておらず、ファイルがそのままある。

$ mount | grep /tmp/mntpoint
$ ls /tmp/mntpoint/
greet.txt
$ cat /tmp/mntpoint/greet.txt 
Hello, World

これこそ Mount Namespace の効果を示している。 つまり、異なる Namespace 上で実施したマウントの操作は、別の Namespace には影響を与えない。

unshare(2) を使って Mount Namespace を操作する

続いては、システムコールを直接使って Mount Namespace を操作してみよう。

早速だけど以下にサンプルコードを示す。 unshare(2) で Mount Namespace を作るには引数に CLONE_NEWNS を指定して呼び出せば良い。 その上で、mount(2) を使って /tmp/mntpoint に tmpfs をマウントしている。 最終的にやるのは execvp(3) を使って bash を起動すること。

#define _GNU_SOURCE

#include <sched.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <unistd.h>
#include <sys/mount.h>

int main(int argc, char *argv[]) {
    // unshare(2) で Mount Namespace を作成する
    if (unshare(CLONE_NEWNS) != 0) {
        fprintf(stderr, "Failed to create a new mount namespace: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }

    // ルートディレクトリから再帰的にマウントのプロパゲーションを無効にする
    if (mount("none", "/", NULL, MS_REC | MS_PRIVATE, NULL) != 0) {
        fprintf(stderr, "Can not change root filesystem propagation: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }

    // tmpfs をマウントする
    char mount_dst[] = "/tmp/mntpoint";
    if (mount("tmpfs", mount_dst, "tmpfs", MS_NOSUID | MS_NODEV, NULL) != 0) {
        fprintf(stderr, "Failed to mount %s: %s\n", mount_dst, strerror(errno));
        exit(EXIT_FAILURE);
    }

    // execvp(3) でシェルを起動する
    char* const args[] = {"bash", NULL};
    if (execvp(args[0], args) != 0) {
        fprintf(stderr, "Failed to exec \"%s\": %s\n", args[0], strerror(errno));
        exit(EXIT_FAILURE);
    }

    return EXIT_SUCCESS;
}

まずは上記に適当な名前をつけて保存したらコンパイルする。

$ gcc -Wall example.c 

できたバイナリを実行・・・する前に、一旦 Mount Namespace の識別子を確認しておこう。

$ file /proc/self/ns/mnt
/proc/self/ns/mnt: symbolic link to mnt:[4026531840]

改めて、できたバイナリをスーパーユーザの権限で実行する。

$ sudo ./a.out

するとシェルが起動するので、また Mount Namespace の識別子を確認する。 ちゃんと先ほどと異なっている。

# file /proc/self/ns/mnt
/proc/self/ns/mnt: symbolic link to mnt:[4026532128]

ソースコードの中でマウントしているので /tmp/mntpoint に tmpfs がマウントされてファイルが見えなくなっている。 さっきとはマウントするときのオプションがちょっと違ってるけど、まあそこは良いか。

# mount | grep /tmp/mntpoint
tmpfs on /tmp/mntpoint type tmpfs (rw,nosuid,nodev,relatime)
# ls /tmp/mntpoint/ | wc -l
0

ひとしきり満足したら元のシェルに戻ろう。

# exit

もちろん、別の Mount Namespace での出来事なので、元のシェルにはマウントされていない。

$ mount | grep /tmp/mntpoint
$ ls /tmp/mntpoint/ | wc -l
1

マウントのプロパゲーションについて

ところで、先ほどのソースコードに次のような処理が入っていることを不思議に感じたと思う。

    // ルートディレクトリから再帰的にマウントのプロパゲーションを無効にする
    if (mount("none", "/", NULL, MS_REC | MS_PRIVATE, NULL) != 0) {
        fprintf(stderr, "cannot change root filesystem propagation: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }

これはマウントのプロパゲーションという機能を抑制するために入れている。 というのも、プロパゲーションの設定によっては Mount Namespace が別れていても、操作が別の Namespace まで伝搬してしまうのだ。

Linux カーネルのデフォルトの設定では伝搬しない (PRIVATE) ようになっている。 しかし、systemd(1) は起動時に伝搬する (SHARED) ように設定を書きかえてしまう。 この振る舞いは以下のドキュメントにも記述がある。

man7.org

実際に確認してみよう。 /proc/self/mountinfo を見ると、マウントポイントごとにプロパゲーションのフラグが確認できる。 以下はルートファイルシステムの設定。

$ cat /proc/self/mountinfo | grep ' / / '
32 1 8:1 / / rw,relatime shared:1 - ext4 /dev/sda1 rw

上記で中ほどにある shared:1 というのが、伝搬する (SHARED) 設定になっていることを示している。 これを伝搬しない (PRIVATE) に設定するには、次のように mount(1) に --make-private オプションをつけて実行する。

$ sudo mount --make-private /

もう一度 /proc/self/mountinfo を見ると、shared:1 だった部分が - になった。

$ cat /proc/self/mountinfo | grep ' / / '
32 1 8:1 / / rw,relatime - ext4 /dev/sda1 rw

この状況であればマウントの操作は伝搬しないので、先ほどの処理は不要になる。 試しに、処理を除外したソースコードを用意して試してみよう。

#define _GNU_SOURCE

#include <sched.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <unistd.h>
#include <sys/mount.h>

int main(int argc, char *argv[]) {
    // Mount Namespace を作成する
    if (unshare(CLONE_NEWNS) != 0) {
        fprintf(stderr, "Failed to create a new mount namespace: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }

    // mount(2) で tmpfs をマウントする
    char mount_dst[] = "/tmp/mntpoint";
    if (mount("tmpfs", mount_dst, "tmpfs", MS_NOSUID | MS_NODEV, NULL) != 0) {
        fprintf(stderr, "Failed to mount %s: %s\n", mount_dst, strerror(errno));
        exit(EXIT_FAILURE);
    }

    // execvp(3) でシェルを起動する
    char* const args[] = {"bash", NULL};
    if (execvp(args[0], args) != 0) {
        fprintf(stderr, "Failed to exec \"%s\": %s\n", args[0], strerror(errno));
        exit(EXIT_FAILURE);
    }

    return EXIT_SUCCESS;
}

上記をコンパイルして実行する。

$ gcc -Wall example.c
$ sudo ./a.out

起動したシェルでは、もちろん tmpfs のマウントが確認できる。

# mount | grep /tmp/mntpoint
tmpfs on /tmp/mntpoint type tmpfs (rw,nosuid,nodev,relatime)
# ls /tmp/mntpoint | wc -l
0

シェルを抜けて戻ると、マウントは見えなくなっている。 ちゃんと伝搬しないことが確認できた。

# exit
$ mount | grep /tmp/mntpoint
$ ls /tmp/mntpoint/
greet.txt

ちなみに、mount(1) に --make-shared を指定すると、プロパゲーションの振る舞いを元に戻せる。

$ sudo mount --make-shared /
$ cat /proc/self/mountinfo | grep ' / / '
32 1 8:1 / / rw,relatime shared:327 - ext4 /dev/sda1 rw

この状況だと、マウントの操作が Namespace を越えて伝搬してしまう。 実際に試してみよう。 先ほどコンパイルしたバイナリを実行する。

$ sudo ./a.out

起動したシェルでは tmpfs のマウントが確認できる。 ここまでは変わらない。

# mount | grep /tmp/mntpoint
tmpfs on /tmp/mntpoint type tmpfs (rw,nosuid,nodev,relatime)
# ls /tmp/mntpoint | wc -l
0

しかし、シェルから抜けた後で確認しても、マウントが維持されている。 これはつまり、マウントの操作が Namespace を越えて伝搬してしまった、ということ。

# exit
$ mount | grep /tmp/mntpoint
tmpfs on /tmp/mntpoint type tmpfs (rw,nosuid,nodev,relatime)
$ ls /tmp/mntpoint/ | wc -l
0

ところで、unshare(1) を使うときはプロパゲーションについて特に意識する必要がなかったことを思い出してほしい。 これは、unshare(1) が最初に示したソースコードと同等の処理を実行しているため。 つまり、デフォルトでルートディレクトリのプロパゲーションを明示的に無効にしている。

まとめ

今回は Mount Namespace を使ってマウントの情報をプロセスごとに隔離してみた。 ただし、プロパゲーションの設定によっては操作が伝搬してしまう点に注意する必要がある。

Apple Silicon 版の Mac で Miniforge を使ってサードパーティ製のパッケージをインストールする

これを書いている現在 (2021-11)、Apple Silicon 版の Mac を使って Python の開発環境を整えようとすると、なかなかしんどい。 しんどさの主な要因は、サードパーティ製のパッケージが Apple Silicon をまだサポートしていない場合が多い点にある。

たとえば、Python で機械学習をしようと思ったら誰でも一度は使ったことがあるはずの scikit-learn もそのひとつ。 現在の最新バージョン (1.0.1) でも、Apple Silicon の環境では pip を使った正攻法ではインストールできない。 ただし、以下のドキュメントを読むとわかるとおり、Apple Silicon の環境でインストールする唯一の方法として Miniforge を使うことが挙げられている。

scikit-learn.org

Miniforge は、ざっくり言うと利用するリポジトリがデフォルトで conda-forge 1 になっている Miniconda と考えれば良い。 また、公式のリポジトリには An emphasis on supporting various CPU architectures (x86_64, ppc64le, and aarch64 including Apple M1). という文言がある。

github.com

話が少し脱線するけど、Anaconda 社の運営する公式リポジトリは、現在では大規模な商用利用が有償になっている。 もし、まだ気づいていない人がいたら注意しよう。 その逃げ道としても、Miniforge は有力な選択肢と考えられる 2

前述した conda-forge のリポジトリでは、コミュニティが Apple Silicon の環境向けにビルドした多数のバイナリパッケージを提供している。 そのため、正攻法でインストールしにくいパッケージもすんなりインストールできることが多い。

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

$ sw_vers 
ProductName:    macOS
ProductVersion: 12.0.1
BuildVersion:   21A559
$ uname -m
arm64
$ sysctl machdep.cpu.brand_string
machdep.cpu.brand_string: Apple M1
$ pyenv --version
pyenv 2.2.2
$ conda -V     
conda 4.10.3

もくじ

下準備

今回は、個人的に使い慣れた Pyenv から Miniforge をインストールする手順にした。 とはいえ、別に公式からインストーラを落としてきて入れる方法でももちろん構わない。 むしろ、普段から Pyenv を使っていないのであれば、そちらの方がハマりにくいと思う。

Pyenv は Homebrew を使ってインストールする。

$ brew install pyenv

シェルは zsh を使っているので、~/.zshrc に Pyenv の設定を追加する。

$ echo $SHELL     
/bin/zsh
$ cat << 'EOF' >> ~/.zshrc
if which pyenv >/dev/null 2>&1; then
  export PYENV_ROOT="$HOME/.pyenv"
  export PATH="$PYENV_ROOT/bin:$PATH"
  eval "$(pyenv init --path)"
  eval "$(pyenv init -)"
fi
EOF
$ source ~/.zshrc

Miniforge をインストールする

続いて、Pyenv から Miniforge をインストールする。 miniforge3 を指定すれば、Python3 版の最新の安定版が入るはず。

$ pyenv install miniforge3

インストールしたら、Pyenv で利用する Python 実行環境として miniforge3 を追加する。

$ cat << 'EOF' >> ~/.pyenv/version
system
miniforge3
EOF

これで conda コマンドが使えるようになる。

$ conda -V
conda 4.10.3

自動で base 環境をアクティベートしないように設定を投入しておく。

$ conda config --set auto_activate_base false

Miniforge を使って仮想環境を作る

仮想環境を作る手順は、一般的な Conda のやり方と変わらない。 まずは conda create で仮想環境を作る。

$ conda create -y -n venv python=3.9

これで base 以外の環境が作られる。

$ conda env list  
# conda environments:
#
base                  *  /Users/amedama/.pyenv/versions/miniforge3
venv                     /Users/amedama/.pyenv/versions/miniforge3/envs/venv

Conda のスクリプト類にパスを通すために conda init を実行して設定を読み込む。

$ conda init zsh
$ source ~/.zshrc

これで conda activate で仮想環境を切り替えられるようになる。

$ conda activate venv
(venv) $ 
(venv) $ python -V                         
Python 3.9.7

作った直後なので最低限のパッケージだけが入っている。

(venv) $ pip list                                             
Package    Version
---------- -------
pip        21.3.1
setuptools 59.2.0
wheel      0.37.0

Miniforge でサードパーティ製のパッケージをインストールする

ここからが今回の本題で、前述した scikit-learn を conda install でインストールしてみよう。

(venv) $ conda install -y scikit-learn

次のように、あっさりインストールできる。 ちなみに一緒に入った NumPy なんかも、現状では正攻法でインストールしようとするとつまずきやすい 3

(venv) $ pip list 
Package       Version
------------- -------
joblib        1.1.0
numpy         1.21.4
pip           21.3.1
scikit-learn  1.0.1
scipy         1.7.3
setuptools    59.2.0
threadpoolctl 3.0.0
wheel         0.37.0

もちろん、ちゃんと例外にならずインポートできる。

(venv) $ python -c "import sklearn"

LightGBM なんかも、このとおり。

(venv) $ conda install -y lightgbm
(venv) $ python -c "import lightgbm as lgb"

まとめ

将来的にはサードパーティ製のパッケージが徐々に Apple Silicon を公式でサポートしていって、Miniforge を使う必要性は薄れていくと思う。 とはいえ、それが一体いつなのかは分からない。 ひとまず、問題ないと感じられる日が来るまでは Apple Silicon 環境では Miniforge を使っておこうと思う。


  1. Anaconda 社ではなく、コミュニティが運営している Conda 形式のパッケージを配布するリポジトリ

  2. 大規模に利用していた企業が有償になったからといってコミュニティにフリーライドし始めるのが正しいのか、という話は別にあるとして

  3. まだ Wheel 形式のバイナリが提供されていないため、ソースコード配布物 (sdist) からビルドすることになる

Multipass を使って Apple Silicon 版の Mac で Ubuntu の仮想マシンを扱う

Apple Silicon (M1) の載った Mac mini を購入してからというもの、ローカルで仮想マシンを手軽に立ち上げる方法を模索している。 Intel 版の Mac であれば Vagrant + VirtualBox を使っていたけど、残念ながら VirtualBox は ISA が x86 / amd64 のシステムでしか動作しない。 Docker for Mac も使っているけど、コンテナではなく仮想マシンが欲しいという状況も往々にしてある。 そんな折、Multipass を使うとデフォルトではゲスト OS が Ubuntu に限られる 1 ものの、Vagrant 並に仮想マシンを手軽に扱えることがわかった。 今回は、そんな Multipass の使い方について書いてみる。

multipass.run

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

$ sw_vers
ProductName:    macOS
ProductVersion: 12.0.1
BuildVersion:   21A559
$ uname -m
arm64
$ sysctl machdep.cpu.brand_string
machdep.cpu.brand_string: Apple M1
$ multipass version                        
multipass   1.8.1+mac
multipassd  1.8.1+mac

もくじ

Multipass とは

Multipass は、Ubuntu の開発をしている Canonical 社が提供しているツール。 Windows, Linux, macOS それぞれのプラットフォームで、同じ操作感で Ubuntu の仮想マシンを扱うことができる。 操作は基本的に CUI (Character User Interface) で実施する。

バックエンドのハイパーバイザとして、各 OS のネイティブなフレームワークを利用できる点が特徴的。 たとえば、デフォルトだと Windows であれば Hyper-V 2、macOS だとHyperKit 3、Linux は QEMU/KVM が使える。

下準備

Multipass は Homebrew を使ってインストールできる。

$ brew install --cask multipass

インストールすると multipass コマンドが使えるようになる。

$ multipass version                        
multipass   1.8.1+mac
multipassd  1.8.1+mac

仮想マシンを起動する

仮想マシンを起動するには multipass launch コマンドを使う。 --name オプションを使うと仮想マシンに名前をつけられる。 ここでは focal という名前をつけた。 また、末尾には Ubuntu のバージョン番号を指定する。 バージョン番号を省略した場合には、最新の LTS (Long Term Support) リリースが使われるらしい。

$ multipass launch --name focal 20.04

初回の起動時にはイメージのダウンロードや検証が入るので、少し時間がかかる。

なお、指定できるバージョン番号 (ディストリビューション) は multipass find コマンドで確認できる。

$ multipass find
Image                       Aliases           Version          Description
18.04                       bionic            20211109         Ubuntu 18.04 LTS
20.04                       focal,lts         20211108         Ubuntu 20.04 LTS
21.04                       hirsute           20211110         Ubuntu 21.04
21.10                       impish            20211103         Ubuntu 21.10
anbox-cloud-appliance                         latest           Anbox Cloud Appliance
minikube                                      latest           minikube is local Kubernetes

起動が終わると multipass ls コマンド (または multipass list) に仮想マシンが表示されるようになる。

$ multipass ls                       
Name                    State             IPv4             Image
focal                   Running           192.168.64.2     Ubuntu 20.04 LTS

仮想マシンのより詳しい情報は multipass info コマンドで確認できる。 デフォルトでは 1GB のメモリと 5GB のディスクが割り当てられるらしい。

$ multipass info focal
Name:           focal
State:          Running
IPv4:           192.168.64.2
Release:        Ubuntu 20.04.3 LTS
Image hash:     a83b747df657 (Ubuntu 20.04 LTS)
Load:           0.62 0.22 0.08
Disk usage:     1.2G out of 4.7G
Memory usage:   135.6M out of 974.8M
Mounts:         --

仮想マシンに割り当てる CPU やメモリ、ディスクの容量などは起動時にカスタマイズできる。 具体的には multipass launch コマンドで --cpus--mem--disk といったオプションを指定すれば良い。 詳細は multipass launch --help から確認できる。

仮想マシンでコマンドを実行する

起動した仮想マシンでは、multipass exec コマンドを使ってコマンドが実行できる。 仮想マシンの名前と、ハイフンを 2 つはさんで実行したいコマンドを指定しよう。

$ multipass exec focal -- lsb_release --description
Description:    Ubuntu 20.04.3 LTS
$ multipass exec focal -- uname -m                 
aarch64

仮想マシンの ISA も、ちゃんと aarch64 になっている。

また、multipass shell コマンドを使えばシェルにログインできる。

$ multipass shell focal                            
Welcome to Ubuntu 20.04.3 LTS (GNU/Linux 5.4.0-90-generic aarch64)

... (snip) ...

ubuntu@focal:~$ 

通常はこちらを使って操作することになりそうだ。

仮想マシンを停止・再開・削除する

仮想マシンを停止するには multipass stop コマンドを使う。 ちなみに -t オプションを使えば分単位で停止するタイミングを遅らせることもできる。

$ multipass stop focal
$ multipass ls           
Name                    State             IPv4             Image
focal                   Stopped           --               Ubuntu 20.04 LTS

同様に、再開するときは multipass start コマンドを使う。

$ multipass start focal
$ multipass ls
Name                    State             IPv4             Image
focal                   Running           192.168.64.2     Ubuntu 20.04 LTS

仮想マシンを削除するときは multipass delete コマンドを使う。 ただし、この時点では完全に削除されてはいない。

$ multipass delete focal
$ multipass ls          
Name                    State             IPv4             Image
focal                   Deleted           --               Not Available

Deleted な状態であれば、まだ multipass recover コマンドで復旧できる。

$ multipass recover focal
$ multipass ls           
Name                    State             IPv4             Image
focal                   Stopped           --               Ubuntu 20.04 LTS
$ multipass start focal
$ multipass exec focal -- lsb_release --description
Description:    Ubuntu 20.04.3 LTS

仮想マシンを完全に削除するには、multipass delete した後に multipass purge する。 これで Deleted な状態の仮想マシンが完全に削除される。

$ multipass delete focal
$ multipass purge
$ multipass ls   
No instances found.

次の検証に移る前に、削除してしまった仮想マシンを作り直しておこう。

$ multipass launch --name focal 20.04

ホスト OS のストレージをゲスト OS でマウントして使う

Multipass にはホスト OS のストレージをゲスト OS でマウントする機能がある。 この機能は両者でファイルを共有するときに有用。

機能を使うときは multipass mount コマンドを使う。 このコマンドは次のような書式になっている。

$ multipass mount <source> <target>

ここで、<target><vm-name><vm-name>:<path> の指定ができる。 :<path> を省略すると <source> と同じパスでマウントしようとする。 試しにカレントディレクトリをマウントしてみよう。 動作確認用にファイルを作っておく。

$ echo "Hello, World" > greet.txt
$ ls
greet.txt

カレントディレクトリを指定して仮想マシンにマウントする。 ・・・が、これは指定するパスにもよるけど次のようなエラーになる恐れがある。

$ multipass mount $(pwd) focal:/mnt
mount failed: source "/Users/amedama/Documents/temporary/multipass" is not readable

上記のエラーは macOS の設定に起因している。 というのも、デフォルトでは Multipass に $HOME 以下の任意のディレクトリはアクセスが許可されていない。

このエラーを解消するには「システム環境設定 > セキュリティとプライバシー > プライバシー > フルディスクアクセス」を選択して、multipassd にチェックを入れる必要がある。 もちろん、これはセキュリティ保護に関係ないパスを使っていれば問題にはならない。

操作した上で、改めてマウントを試みると今度はエラーにならず成功する。

$ multipass mount $(pwd) focal:/mnt

マウントすると、次のようにちゃんと仮想マシンからホスト OS のディレクトリが見られるようになっている。

$ multipass exec focal -- ls /mnt
greet.txt
$ multipass exec focal -- cat /mnt/greet.txt
Hello, World

ゲスト OS がマウントしている情報は multipass info コマンドから確認できる。

$ multipass info focal                      
Name:           focal
State:          Running
IPv4:           192.168.64.3
Release:        Ubuntu 20.04.3 LTS
Image hash:     a83b747df657 (Ubuntu 20.04 LTS)
Load:           0.07 0.05 0.03
Disk usage:     1.2G out of 4.7G
Memory usage:   150.5M out of 974.8M
Mounts:         /Users/amedama/Documents/temporary/multipass => /mnt
                    UID map: 501:default
                    GID map: 20:default

マウントを解除したいときは multipass umount コマンドを使えば良い。

$ multipass umount focal

Multipass の設定を読み書きする

Multipass の設定は multipass get コマンドと multipass set コマンドで読み書きできる。 設定項目自体は multipass help get コマンドから確認できる。

たとえばバックエンドのハイパーバイザは local.driver という設定項目になっている。 Apple Silicon の Mac では、デフォルトで QEMU が使われているようだ。

$ multipass get local.driver
qemu

設定は multipass set コマンドを使って <key>=<value> 形式で書きかえられる。 試しに local.driverhyperkit に切り替えてみようとすると、無効なドライバだとエラーになってしまった。

$ multipass set local.driver=hyperkit
Invalid setting 'local.driver=hyperkit': Invalid driver

ちなみに Intel 版の Mac であれば、デフォルトでは以下のように hyperkit が使われていた。

$ sw_vers                        
ProductName:    macOS
ProductVersion: 12.0.1
BuildVersion:   21A559
$ uname -m
x86_64
$ multipass version   
multipass   1.8.1+mac
multipassd  1.8.1+mac
$ multipass get local.driver
hyperkit

試しに上記 Intel 版の環境を使って、バックエンドを VirtualBox に変更してみよう。

$ brew install --cask virtualbox
$ multipass set local.driver=virtualbox

設定を変更すると Multipass のデーモンが再起動する。 再起動が終わらないうちに操作しようとすると、次のようなエラーになる。

launch failed: cannot connect to the multipass socket                           
Please ensure multipassd is running and '/var/run/multipass_socket' is accessible

少し待つと、ちゃんと使えるようになる。 バックエンドを切りかえた上で、試しに Ubuntu 18.04 LTS を起動してみよう。

$ multipass launch --name bionic 18.04

VBoxManage list vms コマンドで確認すると、ちゃんと VirtualBox 上で仮想マシンが立ち上がっているようだ。 ちなみに sudo をつけていることからわかるようにスーパーユーザで実行されている。

$ sudo VBoxManage list vms
"bionic" {afb0cdcb-fbd2-4d01-9310-4abd63fa2b9d}

ゲスト OS 上で IP アドレスを確認すると 、10.0.2.15/24 と、たしかに VirtualBox っぽいアドレスが振られている。

$ multipass exec bionic -- ip address show | grep inet
    inet 127.0.0.1/8 scope host lo
    inet6 ::1/128 scope host 
    inet 10.0.2.15/24 brd 10.0.2.255 scope global dynamic enp0s3
    inet6 fe80::5054:ff:fe0e:1caa/64 scope link 

バックエンドを元に戻したいときは、また HyperKit を使うように local.driver を設定すれば良い。

$ sudo multipass set local.driver=hyperkit

まとめ

Multipass を使うことで、Apple Silicon 版の Mac であっても、デフォルトでゲスト OS は Ubuntu に限られるものの簡単に仮想マシンを扱えることがわかった。 最近は、仮想マシンを立ち上げたいときが大体 Ubuntu を動かしたいときとほぼイコールだったので、ひとまず自分的にはこれで十分かもしれない。 IaaS のインスタンスを立ち上げて使うほどでもないくらいの簡単な検証がローカルでやりやすくなるのは嬉しい。


  1. 自分でイメージを作ることもできるらしい。

  2. ただし、利用できる Windows のエディションやバージョンには制約があるようだ。

  3. 裏側で Hypervisor.framework が動作する。なお、Apple Silicon ではデフォルトで QEMU が使われるようだ。

Overlay Filesystem と Docker について

Linux で利用できるファイルシステムの一つに Overlay Filesystem (OverlayFS) がある。 このファイルシステムは、Docker が推奨しているストレージドライバの overlay2 が利用していることで有名。 今回は、そんな OverlayFS を Docker を介さずに扱ってみる。

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

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=20.04
DISTRIB_CODENAME=focal
DISTRIB_DESCRIPTION="Ubuntu 20.04.3 LTS"
$ uname -rm
5.11.0-1021-gcp x86_64
$ sudo docker version
Client: Docker Engine - Community
 Version:           20.10.9
 API version:       1.41
 Go version:        go1.16.8
 Git commit:        c2ea9bc
 Built:             Mon Oct  4 16:08:29 2021
 OS/Arch:           linux/amd64
 Context:           default
 Experimental:      true

Server: Docker Engine - Community
 Engine:
  Version:          20.10.9
  API version:      1.41 (minimum version 1.12)
  Go version:       go1.16.8
  Git commit:       79ea9d3
  Built:            Mon Oct  4 16:06:37 2021
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          1.4.11
  GitCommit:        5b46e404f6b9f661a205e28d59c982d3634148f8
 runc:
  Version:          1.0.2
  GitCommit:        v1.0.2-0-g52b36a2
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0

もくじ

下準備

OverlayFS は Linux カーネルに組み込まれているため、利用する上で特に必要なパッケージはない。 しいていえば、mount(8) がないとユーザ空間から簡単に操作する手段がないくらい。 ただし、今回は最終的に Docker イメージを元に手動で OverlayFS をマウントして unshare(1) でコンテナもどきを作りたい。 そのために、あらかじめ jq と Docker をインストールしておく。

まずは jq を入れる。

$ sudo apt-get update
$ sudo apt-get install jq

続いて Docker を入れる。

$ sudo apt-get remove docker docker-engine docker.io containerd runc
$ sudo apt-get install \
    apt-transport-https \
    ca-certificates \
    curl \
    gnupg \
    lsb-release
$ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
$ echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu \
  $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
$ sudo apt-get update
$ sudo apt-get install docker-ce docker-ce-cli containerd.io

Docker が動作することを確認しておく。

$ sudo docker run hello-world

... (snip) ...

Hello from Docker!
This message shows that your installation appears to be working correctly.

... (snip) ...

これで準備が整った。

OverlayFS を読み取り専用で使ってみる

OverlayFS は、別のファイルシステムの上で動作する。 たとえば今回であれば ext4 で構築されたファイルシステム上で扱う。 なお、ドキュメントによると NFS 上で扱う場合には制限事項があるようだ。

$ df -T | head -n 3
Filesystem     Type     1K-blocks    Used Available Use% Mounted on
/dev/root      ext4       9983232 4428432   5538416  45% /
devtmpfs       devtmpfs   4068948       0   4068948   0% /dev

OverlayFS では、ファイルシステム上のディレクトリを重ねてマージした状態で扱うことができる。 これは、あまり言葉で説明しても分かりにくいと思うので、以下に例を示す。

まずは重ねるディレクトリとして lower1lower2 を用意する。 そして、マウントポイントとして merged というディレクトリも用意する。

$ mkdir lower1 lower2 merged

lower1lower2 に、同じ名前を持った a というファイルを用意しよう。 それぞれのファイルには、どちらのディレクトリ由来なのかがわかるようにテキストを書き込んでおく。

$ echo "lower1" > lower1/a
$ echo "lower2" > lower2/a

また、lower2 にだけ存在するファイルとして b も用意する。

$ echo "lower2" > lower2/b

上記の lower1lower2 を OverlayFS で重ね合わせて、merged にマウントしてみよう。 このとき lowerdir にコロン区切りでディレクトリを指定する。

$ sudo mount -t overlay overlay -o lowerdir=lower1:lower2 merged

マウント先を確認すると、mergedab というファイルがある。

$ ls merged/
a  b

ファイルの中身を確認すると、alower1 のものが使われている。 ここから、lowerdir は最初 (左側) に登場したディレクトリの内容が優先されることがわかる。 なお、lower2 にしか存在しない b は、当然ながらそれ由来になる。

$ cat merged/a 
lower1
$ cat merged/b
lower2

なお、lowerdir だけを指定した場合には、ファイルシステムは読み取り専用になる。 これは、lowerdir はファイルシステムの元ネタに過ぎないため、変更点を書き込む場所が存在しないため。

$ echo "Hello, World" > merged/c
-bash: merged/c: Read-only file system

基本的な使い方が確認できたところでアンマウントしておこう。

$ sudo umount merged

アンマウントすると中身は空っぽに戻る。

$ ls merged/

OverlayFS を書き込める状態で使ってみる

続いては書き込み可能なファイルシステムを作ってみよう。

書き込み可能にする場合には workdirupperdir というディレクトリを指定する必要がある。 まずはそれに使うディレクトリを作っておこう。

$ mkdir work upper

先ほどと同じ要領でマウントする。 ただし、今回はオプションに workdirupperdir を指定する。

$ sudo mount -t overlay overlay -o lowerdir=lower1:lower2,workdir=work,upperdir=upper merged

すると、今度はマウントしたファイルシステムに書き込みが可能になる。 試しに c というファイルを書き込んでみよう。

$ echo "Hello, World" > merged/c
$ cat merged/c
Hello, World

変更点は upperdir に指定したディレクトリに書き込まれていることがわかる。

$ ls upper
c
$ cat upper/c 
Hello, World

なお、もちろんファイルを削除することもできる。

$ rm merged/b

削除されたファイルの情報はデバイス番号が 0/0 のキャラクタデバイスファイルとして表現される。

$ ls upper/
b  c
$ file upper/b
upper/b: character special (0/0)

動作確認が終わったら、アンマウントして書き込まれた内容を掃除しておこう。

$ sudo umount merged
$ rm -rf upper/*

Docker イメージを元に手動でコンテナもどきを作ってみる

さて、ここまでで OverlayFS の基本的な使い方がわかった。 次は Docker イメージを元に、OverlayFS を使って手動でコンテナもどきの環境を作ってみることにしよう。

まずは適当なコンテナイメージを取得する。 今回は python:3.9-slim を使うことにした。

$ sudo docker image pull python:3.9-slim

試しに取得したイメージを使ってコンテナを立ち上げてみよう。

$ sudo docker container run --rm -it python:3.9-slim bash

次のように、このイメージでは Python 3.9 が利用できる。

# ls /
bin   dev  home  lib64  mnt  proc  run   srv  tmp  var
boot  etc  lib   media  opt  root  sbin  sys  usr
# which python3
/usr/local/bin/python3
# python3 -V
Python 3.9.7

ちなみに、今回使っているシステムにインストールされている Python はバージョンが 3.8 になっている。

$ python3 -V
Python 3.8.10

さて、取得したイメージを docker inspect で確認してみよう。 すると、イメージを構成しているディレクトリの情報が含まれることがわかる。 おや?この名称は OverlayFS で使われているものと似ていないだろうか。

$ sudo docker inspect python:3.9-slim | jq .[0].GraphDriver.Data
{
  "LowerDir": "/var/lib/docker/overlay2/78920b19e12a8ada04d603b0c5565e8e30fc9139c929aa291e8e45118eb1fede/diff:/var/lib/docker/overlay2/9cea339dd2014f814c5b69087ba70aa62df4993f9aa74d6f4aacfd5fca5e156b/diff:/var/lib/docker/overlay2/ea324ac31f37d8a379fec3c132f2684d8928b11db6e83eb3922c93a14a674340/diff:/var/lib/docker/overlay2/4823ba02e2349749afa3a71b55c630b5a90decb0462fc136f1c2c246648ee540/diff",
  "MergedDir": "/var/lib/docker/overlay2/b5e26155a3241b7fc8df4497387d166687c09d3bdbf3ce56fe71899f209d6c87/merged",
  "UpperDir": "/var/lib/docker/overlay2/b5e26155a3241b7fc8df4497387d166687c09d3bdbf3ce56fe71899f209d6c87/diff",
  "WorkDir": "/var/lib/docker/overlay2/b5e26155a3241b7fc8df4497387d166687c09d3bdbf3ce56fe71899f209d6c87/work"
}

LowerDir に含まれるディレクトリを確認すると、これがイメージを構成している「レイヤー」の実体であることがわかる。

$ sudo find /var/lib/docker/overlay2/78920b19e12a8ada04d603b0c5565e8e30fc9139c929aa291e8e45118eb1fede/diff
/var/lib/docker/overlay2/78920b19e12a8ada04d603b0c5565e8e30fc9139c929aa291e8e45118eb1fede/diff
/var/lib/docker/overlay2/78920b19e12a8ada04d603b0c5565e8e30fc9139c929aa291e8e45118eb1fede/diff/usr
/var/lib/docker/overlay2/78920b19e12a8ada04d603b0c5565e8e30fc9139c929aa291e8e45118eb1fede/diff/usr/local
/var/lib/docker/overlay2/78920b19e12a8ada04d603b0c5565e8e30fc9139c929aa291e8e45118eb1fede/diff/usr/local/bin
/var/lib/docker/overlay2/78920b19e12a8ada04d603b0c5565e8e30fc9139c929aa291e8e45118eb1fede/diff/usr/local/bin/pydoc
/var/lib/docker/overlay2/78920b19e12a8ada04d603b0c5565e8e30fc9139c929aa291e8e45118eb1fede/diff/usr/local/bin/python-config
/var/lib/docker/overlay2/78920b19e12a8ada04d603b0c5565e8e30fc9139c929aa291e8e45118eb1fede/diff/usr/local/bin/idle
/var/lib/docker/overlay2/78920b19e12a8ada04d603b0c5565e8e30fc9139c929aa291e8e45118eb1fede/diff/usr/local/bin/python
$ sudo ls /var/lib/docker/overlay2/4823ba02e2349749afa3a71b55c630b5a90decb0462fc136f1c2c246648ee540/diff
bin  boot  dev  etc  home  lib  lib64  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var

なんとなく LowerDir の中身は、そのまま OverlayFS のオプションとして渡せそうだ。

$ sudo docker inspect python:3.9-slim | jq -r .[0].GraphDriver.Data.LowerDir
/var/lib/docker/overlay2/78920b19e12a8ada04d603b0c5565e8e30fc9139c929aa291e8e45118eb1fede/diff:/var/lib/docker/overlay2/9cea339dd2014f814c5b69087ba70aa62df4993f9aa74d6f4aacfd5fca5e156b/diff:/var/lib/docker/overlay2/ea324ac31f37d8a379fec3c132f2684d8928b11db6e83eb3922c93a14a674340/diff:/var/lib/docker/overlay2/4823ba02e2349749afa3a71b55c630b5a90decb0462fc136f1c2c246648ee540/diff

物は試しということで、先ほどと同じ要領で OverlayFS をマウントしてみよう。

$ sudo mount -t overlay overlay -o lowerdir=$(sudo docker inspect python:3.9-slim | jq -r .[0].GraphDriver.Data.LowerDir),workdir=work,upperdir=upper merged

何だか上手くいった感じがする。

$ ls merged/
bin  boot  dev  etc  home  lib  lib64  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var

ここでおもむろに unshare(1) を使う。 unshare(1) は内部的に unshare(2) を呼んでプロセスのアイソレーションを操作できるコマンド。 以下で指定している -R オプションは、プロセスのルートを指定したディレクトリに変更するというもの。 このとき、起動するプロセスとしては bash などのシェルを指定しよう。

$ sudo unshare -R merged bash

すると、OverlayFS のディレクトリをルートとして持ったシェルのプロセスが誕生する。

# ls /
bin  boot  dev  etc  home  lib  lib64  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var

このプロセスはシステムとは違って Python 3.9 が利用できる。

# which python3
/usr/local/bin/python3
# python3 -V
Python 3.9.7
# python3 -c "print('Hello, World')"
Hello, World

ということで、Docker イメージを元ネタに OverlayFS を直接使ってコンテナっぽいものを作ることができた。 もちろん、このプロセスはファイルシステム以外にはシステムとのアイソレーションができていない。 とはいえ、Docker (のコンテナランタイム) が内部的にやっていることは本質的に上記と同じこと。

いじょう。

参考

https://www.kernel.org/doc/Documentation/filesystems/overlayfs.txt

Python: PyTorch の MultiheadAttention を検算してみる

今回は、言わずと知れた Transformer 1 において、処理の中心的な役割を果たしている (とされる) Multi-Head Attention を扱ってみる。 これは、Scaled Dot Product Attention という処理を改良したもの。

PyTorch には Multi-Head Attention の実装として MultiheadAttention というクラスが用意されている。 今回は、これがどういった処理をしているのかを、検算しながら確かめてみる。

pytorch.org

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

$ sw_vers
ProductName:    macOS
ProductVersion: 11.6
BuildVersion:   20G165
$ python -V          
Python 3.9.7
$ pip list | grep -i torch
torch             1.9.1

もくじ

下準備

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

$ pip install torch numpy

続いて、Python のインタプリタを起動しておこう。

$ python

PyTorch 関連のモジュールをインポートしておく。

>>> import torch
>>> from torch import nn
>>> from torch.nn import functional as F

検算する

Multi-Head Attention は、Query と Key と Value (以下、Q, K, V) という 3 つのパラメータを入力として受け取る。 それぞれのパラメータは同じ次元数で、返す値は Query と同一の形状になるという特徴がある。 なお、Attention 自体の説明は以下のブログが詳しい。

deeplearning.hatenablog.com

はじめに、返り値の次元数を定義する。 この次元数は、前述のとおり Q の次元数と同じになる。

>>> embed_dim = 4

続いてヘッド数を定義する。 ヘッドというのは、入力をいくつかに分割して処理するそれぞれの Scaled Dot Product Attention のこと。 元の入力データの次元数が \displaystyle{d _ {model}} とすると、各ヘッドに入力されるデータの次元数は  \frac{d_{model}}{h} になる。 ここで  h がヘッド数を表す。 ヘッド数はハイパーパラメータで、多すぎても少なすぎても良くないらしい。 まずは単純なケースとしてヘッド数が 1 の場合を確かめよう。

>>> num_heads = 1

定義した次元数とヘッド数を使って MultiheadAttention をインスタンス化する。 今回は単純にするためバイアス項をなくし、入力データの形状としてバッチが最初にくるようにした。

>>> model = nn.MultiheadAttention(embed_dim=embed_dim,
...                               num_heads=num_heads,
...                               bias=False,
...                               batch_first=True)

つづいて、上記に入力するダミーデータを用意する。 今回は 2 x 5 x 4 という形状のデータにした。 仮に自然言語を想定するなら、最初がバッチ、2 番目が文章の系列長、最後が単語の分散表現の次元数を表すことになる。 それぞれ batch_sizeLembed_dim という変数名で用意している。

>>> batch_size = 2  # 一度に処理するデータの数
>>> L = 5  # 入力する系列データの長さ
>>> X = torch.randn(batch_size, L, embed_dim)  # ダミーの入力データ

今回は Transformer でも用いられている Self Attention を想定するので Q, K, V すべてに同じ X を入れる。

>>> Q = K = V = X

モデルとデータが用意できたので順伝搬させて返り値を得よう。 返り値としては、変換された Q と同一形状のテンソルと、モデルが何に注目したかを表す Attention Weights (Map とも) が返ってくる。

>>> attn_output, attn_weights = model(Q, K, V)

もちろん、上記は初期値の重みとダミーデータを使って得たものなので、中身自体には何の意味があるわけでもない。 しかし、どういった計算を経てこれが得られるのかを確かめる分には十分だ。

以下を見てわかるとおり、出力は入力した Query と同じ形状になっている。

>>> attn_output
tensor([[[-0.1453, -0.1466, -0.0371,  0.3497],
         [-0.1822, -0.1085, -0.0900,  0.3613],
         [-0.0751, -0.1506,  0.0799,  0.2830],
         [-0.1552, -0.1184, -0.0450,  0.3429],
         [-0.2029, -0.0971, -0.1205,  0.3772]],

        [[-0.2694, -0.3017, -0.4528,  0.5128],
         [-0.3347, -0.3734, -0.4705,  0.6975],
         [-0.2867, -0.3250, -0.4454,  0.5810],
         [-0.2401, -0.2732, -0.4402,  0.4353],
         [-0.2539, -0.2964, -0.4241,  0.5060]]], grad_fn=<TransposeBackward0>)
>>> attn_output.shape
torch.Size([2, 5, 4])

Attention Weights は、先頭がバッチで、2 番目と 3 番目が系列長 L と同じ形状になる。

>>> attn_weights
tensor([[[0.2358, 0.2097, 0.2365, 0.1587, 0.1592],
         [0.1823, 0.1938, 0.1635, 0.2238, 0.2366],
         [0.2124, 0.1384, 0.3916, 0.1622, 0.0953],
         [0.1892, 0.1843, 0.2213, 0.2131, 0.1921],
         [0.1687, 0.2126, 0.1319, 0.2141, 0.2727]],

        [[0.2454, 0.1563, 0.1772, 0.2531, 0.1680],
         [0.2078, 0.2083, 0.2640, 0.1586, 0.1612],
         [0.2298, 0.2132, 0.1977, 0.1992, 0.1602],
         [0.2408, 0.1406, 0.1461, 0.2921, 0.1804],
         [0.2226, 0.2197, 0.1611, 0.2224, 0.1742]]], grad_fn=<DivBackward0>)
>>> attn_weights.shape
torch.Size([2, 5, 5])

さて、それでは上記の結果がどのように得られるのかを検算で確かめてみよう。

まず、PyTorch のドキュメントを見ると MultiheadAttention の数式は以下のように定義されている。

\displaystyle{

\text{MultiHead}(Q, K, V) = \text{Concat}(\text{head}_1, \dots, \text{head}_h)W^O \\

\text{head}_i = \text{Attention}(QW_i^Q, KW_i^K, VW_i^V)

}

Q, K, V それぞれを、パラメータ行列 \displaystyle{W_i} と積をとって Attention に入れたものが、各ヘッドの出力になる。 各ヘッドの出力は連結した上で、さらにパラメータ行列 \displaystyle{W^ O} とかけたものが MultiheadAttention の出力だ。

ここで   \text{Attention} は Scaled Dot Product Attention なので以下になる。

\displaystyle{
\text{Attention}(Q, K, V) = \text{softmax}(\frac{Q K^T}{d_q}) V 
}

数式がわかったので、まずはモデルが持っているパラメータを確認してみよう。 どうやら in_proj_weightsout_proj.weight というパラメータがあるらしい。 これらは、上記の式において  W_i と [tex: WO] に対応している。

>>> from pprint import pprint
>>> pprint(list(model.named_parameters()))
[('in_proj_weight',
  Parameter containing:
tensor([[-0.5443,  0.3884, -0.1312, -0.1092],
        [ 0.1386, -0.3444,  0.3273,  0.1445],
        [-0.2816,  0.0416, -0.4813,  0.1620],
        [-0.4794, -0.0049, -0.5191, -0.3294],
        [-0.3429,  0.4189, -0.0930,  0.2866],
        [ 0.5036, -0.2311,  0.2426,  0.0193],
        [ 0.5196, -0.0979, -0.4762, -0.3478],
        [-0.3660, -0.3218, -0.2310, -0.2840],
        [-0.4351,  0.1184, -0.3720, -0.2419],
        [-0.2723, -0.5269,  0.2075, -0.4505],
        [ 0.0627,  0.0975,  0.5494, -0.2860],
        [ 0.4284,  0.5447, -0.1266,  0.2931]], requires_grad=True)),
 ('out_proj.weight',
  Parameter containing:
tensor([[-0.0758,  0.0238, -0.4159,  0.4350],
        [ 0.1650, -0.2046,  0.4133,  0.2710],
        [ 0.4356, -0.0973, -0.1273,  0.3115],
        [ 0.3645,  0.4667,  0.4714, -0.4997]], requires_grad=True))]

それぞれの行列をモデルから取り出しておこう。

>>> model_weights = {name: param.data for name, param
...                  in model.named_parameters()}
>>> 
>>> Wi = model_weights['in_proj_weight']
>>> Wo = model_weights['out_proj.weight']

Wi は Q, K, V それぞれにかける部位が分かれているので取り出す。

>>> Wi_q, Wi_k, Wi_v = Wi.chunk(3)

取り出したら次のようにして Attention に入力する部分を計算する。

>>> QW = torch.matmul(Q, Wi_q.T)
>>> KW = torch.matmul(K, Wi_k.T)
>>> VW = torch.matmul(V, Wi_v.T)

数式で対応するのは、以下の Attention に渡す前の部分。

\displaystyle{

 \text{Attention}(QW_i^Q, KW_i^K, VW_i^V)

}

なお、今回は Self Attention なので、次のように計算しても結果は変わらない。

>>> QW, KW, VW = torch.matmul(Q, Wi.T).chunk(3, dim=-1)

続いては Scaled Dot Product Attention の中の処理に入る。 まずは \displaystyle{
Q K^ T
} を計算する。

>>> KW_t = KW.transpose(-2, -1)
>>> QK_t = torch.bmm(QW, KW_t)

次にスケールを調整する。 数式だと \displaystyle{
\frac{Q K^ T}{d _ q}
} の部分。

>>> import math
>>> QK_t_scaled = QK_t / math.sqrt(embed_dim)

Softmax を通して足して 1 になるようにする。 \displaystyle{
 \text{softmax}(\frac{Q K^ T}{d _ q})
} の部分。 これが Attention Weights と呼ばれるもの。

>>> attn_weights_ = F.softmax(QK_t_scaled, dim=-1)

モデルから得られた値と比較すると、ちゃんと一致していることがわかる。

>>> attn_weights
tensor([[[0.2358, 0.2097, 0.2365, 0.1587, 0.1592],
         [0.1823, 0.1938, 0.1635, 0.2238, 0.2366],
         [0.2124, 0.1384, 0.3916, 0.1622, 0.0953],
         [0.1892, 0.1843, 0.2213, 0.2131, 0.1921],
         [0.1687, 0.2126, 0.1319, 0.2141, 0.2727]],

        [[0.2454, 0.1563, 0.1772, 0.2531, 0.1680],
         [0.2078, 0.2083, 0.2640, 0.1586, 0.1612],
         [0.2298, 0.2132, 0.1977, 0.1992, 0.1602],
         [0.2408, 0.1406, 0.1461, 0.2921, 0.1804],
         [0.2226, 0.2197, 0.1611, 0.2224, 0.1742]]], grad_fn=<DivBackward0>)
>>> attn_weights_
tensor([[[0.2358, 0.2097, 0.2365, 0.1587, 0.1592],
         [0.1823, 0.1938, 0.1635, 0.2238, 0.2366],
         [0.2124, 0.1384, 0.3916, 0.1622, 0.0953],
         [0.1892, 0.1843, 0.2213, 0.2131, 0.1921],
         [0.1687, 0.2126, 0.1319, 0.2141, 0.2727]],

        [[0.2454, 0.1563, 0.1772, 0.2531, 0.1680],
         [0.2078, 0.2083, 0.2640, 0.1586, 0.1612],
         [0.2298, 0.2132, 0.1977, 0.1992, 0.1602],
         [0.2408, 0.1406, 0.1461, 0.2921, 0.1804],
         [0.2226, 0.2197, 0.1611, 0.2224, 0.1742]]])

続いては Attention Weights を重みとした、V の重みつき和を得る。 これで \displaystyle{
\text{head} _ i = \text{Attention}(QW _ i^ Q, KW _ i^ K, VW _ i^ V)
} に対応するヘッドの出力が得られた。

>>> AV = torch.matmul(attn_weights_, VW)

今回、ヘッド数が 1 なので連結処理は必要ない。 あとはヘッドの出力を \displaystyle{
W^ O
} とかけるだけ。

>>> attn_output_ = torch.matmul(AV, Wo.T)

最初にモデルから得られた値と比較すると、ちゃんと一致していることがわかる。

>>> attn_output
tensor([[[-0.1453, -0.1466, -0.0371,  0.3497],
         [-0.1822, -0.1085, -0.0900,  0.3613],
         [-0.0751, -0.1506,  0.0799,  0.2830],
         [-0.1552, -0.1184, -0.0450,  0.3429],
         [-0.2029, -0.0971, -0.1205,  0.3772]],

        [[-0.2694, -0.3017, -0.4528,  0.5128],
         [-0.3347, -0.3734, -0.4705,  0.6975],
         [-0.2867, -0.3250, -0.4454,  0.5810],
         [-0.2401, -0.2732, -0.4402,  0.4353],
         [-0.2539, -0.2964, -0.4241,  0.5060]]], grad_fn=<TransposeBackward0>)
>>> attn_output_
tensor([[[-0.1453, -0.1466, -0.0371,  0.3497],
         [-0.1822, -0.1085, -0.0900,  0.3613],
         [-0.0751, -0.1506,  0.0799,  0.2830],
         [-0.1552, -0.1184, -0.0450,  0.3429],
         [-0.2029, -0.0971, -0.1205,  0.3772]],

        [[-0.2694, -0.3017, -0.4528,  0.5128],
         [-0.3347, -0.3734, -0.4705,  0.6975],
         [-0.2867, -0.3250, -0.4454,  0.5810],
         [-0.2401, -0.2732, -0.4402,  0.4353],
         [-0.2539, -0.2964, -0.4241,  0.5060]]])

これで、出力と Attention Weights の両方について、モデルから得られたものと計算した値が一致した。

ヘッド数を増やしてみる

さて、先ほどの検算ではヘッド数が 1 の単純なパターンを試した。 続いてはヘッド数を 2 に増やして同様のことをやってみよう。 ちゃんとできるだろうか。

まずはヘッド数を 2 に増やした上で MultiheadAttention をインスタンス化し直す。

>>> num_heads = 2
>>> model = nn.MultiheadAttention(embed_dim=embed_dim,
...                               num_heads=num_heads,
...                               bias=False,
...                               batch_first=True)

ダミーデータはそのまま流用して、また返り値を得よう。

>>> attn_output, attn_weights = model(Q, K, V)

モデルからパラメータを取り出す。

>>> model_weights = {name: param.data for name, param
...                  in model.named_parameters()}
>>> Wi = model_weights['in_proj_weight']
>>> Wo = model_weights['out_proj.weight']

今回、ヘッド数が 1 から 2 に増えるので、ヘッドに入力されるデータの次元数は半分になる。 そこを計算しておこう。

>>> embed_dim_per_head = embed_dim // num_heads
>>> embed_dim_per_head
2

まずは \displaystyle{
W _ i
} の方のパラメータ行列を先ほどと同じように取り出そう。

>>> Wi_q, Wi_k, Wi_v = Wi.chunk(3)

上記の行列は、ヘッド毎に使う部分が分かれている。 そこで、0 番目のヘッド用と 1 番目のヘッド用に分割して取り出す。

>>> Wi0_q, Wi1_q = Wi_q.chunk(num_heads)
>>> Wi0_k, Wi1_k = Wi_k.chunk(num_heads)
>>> Wi0_v, Wi1_v = Wi_v.chunk(num_heads)

Scaled Dot Product Attention の計算はやることが多いので一旦関数にまとめてしまおう。

>>> def scaled_dot_product_self_attention(X, Wi_q, Wi_k, Wi_v):
...     QW = torch.matmul(Q, Wi_q.T)
...     KW = torch.matmul(K, Wi_k.T)
...     VW = torch.matmul(V, Wi_v.T)
...     KW_t = KW.transpose(-2, -1)
...     QK_t = torch.bmm(QW, KW_t)
...     import math
...     QK_t_scaled = QK_t / math.sqrt(embed_dim_per_head)
...     attn_weights_ = F.softmax(QK_t_scaled, dim=-1)
...     AV = torch.matmul(attn_weights_, VW)
...     return AV, attn_weights_
... 

次のようにヘッドごとの計算結果を得る。

>>> AV0, attn_weights0_ = scaled_dot_product_self_attention(Q, Wi0_q, Wi0_k, Wi0_v)
>>> AV1, attn_weights1_ = scaled_dot_product_self_attention(Q, Wi1_q, Wi1_k, Wi1_v)

ヘッドごとの計算結果を連結する。 数式では \displaystyle{
\text{Concat}(\text{head} _ 1, \dots, \text{head} _ h)
} に対応する。

>>> AV_concat = torch.cat([AV0, AV1], dim=-1)

連結したらあとはさっきと同じ。

>>> attn_output_ = torch.matmul(AV_concat, Wo.T)

結果を確認すると、ちゃんとモデルの出力と一致している。

>>> attn_output
tensor([[[ 0.0637, -0.1713,  0.1646,  0.1171],
         [ 0.2022, -0.2627,  0.1944,  0.0561],
         [ 0.1051, -0.1979,  0.1749,  0.0978],
         [ 0.1367, -0.2148,  0.1865,  0.0759],
         [ 0.1614, -0.2310,  0.1923,  0.0644]],

        [[-0.3877, -0.1922, -0.1522,  0.6143],
         [-0.4006, -0.1432, -0.1958,  0.5701],
         [-0.3928, -0.2885, -0.1149,  0.7077],
         [-0.3871, -0.1448, -0.1641,  0.5711],
         [-0.4203, -0.1682, -0.1782,  0.6106]]], grad_fn=<TransposeBackward0>)
>>> attn_output_
tensor([[[ 0.0637, -0.1713,  0.1646,  0.1171],
         [ 0.2022, -0.2627,  0.1944,  0.0561],
         [ 0.1051, -0.1979,  0.1749,  0.0978],
         [ 0.1367, -0.2148,  0.1865,  0.0759],
         [ 0.1614, -0.2310,  0.1923,  0.0644]],

        [[-0.3877, -0.1922, -0.1522,  0.6143],
         [-0.4006, -0.1432, -0.1958,  0.5701],
         [-0.3928, -0.2885, -0.1149,  0.7077],
         [-0.3871, -0.1448, -0.1641,  0.5711],
         [-0.4203, -0.1682, -0.1782,  0.6106]]])

ちなみに Attention Weights はどうなっているかというと、すべてのヘッドの平均を計算しているようだ。

>>> attn_weights_ = (attn_weights0_ + attn_weights1_) / num_heads

平均をとったものが、モデルの出力と一致している。

>>> attn_weights
tensor([[[0.2021, 0.1750, 0.1170, 0.2130, 0.2929],
         [0.2060, 0.1956, 0.2399, 0.1871, 0.1713],
         [0.2028, 0.1883, 0.1492, 0.2120, 0.2476],
         [0.1966, 0.2012, 0.1703, 0.2065, 0.2254],
         [0.1972, 0.2058, 0.1914, 0.2004, 0.2052]],

        [[0.1916, 0.2228, 0.1979, 0.1893, 0.1984],
         [0.1783, 0.2872, 0.1692, 0.1739, 0.1914],
         [0.1891, 0.0948, 0.2491, 0.2291, 0.2379],
         [0.1943, 0.2597, 0.1843, 0.1796, 0.1822],
         [0.2045, 0.1544, 0.2107, 0.2196, 0.2110]]], grad_fn=<DivBackward0>)
>>> attn_weights_
tensor([[[0.2021, 0.1750, 0.1170, 0.2130, 0.2929],
         [0.2060, 0.1956, 0.2399, 0.1871, 0.1713],
         [0.2028, 0.1883, 0.1492, 0.2120, 0.2476],
         [0.1966, 0.2012, 0.1703, 0.2065, 0.2254],
         [0.1972, 0.2058, 0.1914, 0.2004, 0.2052]],

        [[0.1916, 0.2228, 0.1979, 0.1893, 0.1984],
         [0.1783, 0.2872, 0.1692, 0.1739, 0.1914],
         [0.1891, 0.0948, 0.2491, 0.2291, 0.2379],
         [0.1943, 0.2597, 0.1843, 0.1796, 0.1822],
         [0.2045, 0.1544, 0.2107, 0.2196, 0.2110]]])

これで、複数のヘッドがあるパターンについてもモデルと計算した値が一致することがわかった。

まとめ

今回は Transformer の中心的な処理である Multi-Head Attention について、PyTorch の実装を例に検算してみた。

Python: Luigi の DateIntervalParameter について

バッチ処理に特化した Python のデータパイプライン構築用のフレームワークに Luigi がある。 今回は、特定の時系列的な範囲を Task が受け取るのに使える DateIntervalParameter というパラメータを紹介する。 これは、たとえば一週間とか一ヶ月あるいは特定の日付から日付といった範囲で、何らかの集計をする処理を書くときに便利に使える。

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

$ sw_vers
ProductName:    macOS
ProductVersion: 11.6
BuildVersion:   20G165
$ python -V        
Python 3.9.7
$ pip list | grep -i luigi
luigi                    3.0.3

もくじ

下準備

あらかじめ、Luigi をインストールしておく。

$ pip install luigi

DateIntervalParameter について

早速だけど以下にサンプルコードを示す。

以下では ExampleTask というタスクを定義している。 このタスクは dt_interval という名前で DateIntervalParameter 型のパラメータを受け取る。 タスクが実行されると DateIntervalParameter#dates() メソッドを呼んで、範囲に含まれる日付を標準出力に書き出す。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-


import luigi


class ExampleTask(luigi.Task):
    # 期間を指定できるパラメータ
    dt_interval = luigi.DateIntervalParameter()

    def run(self):
        for date in self.dt_interval.dates():
            # 各日付に適用する擬似的な処理
            print(f'Processing: {date}')

    def complete(self):
        # 動作確認用に output() メソッドを定義しないで常にタスクが実行されるようにする
        return False

上記を実行してみよう。 まずは "2021-W01" という文字列を渡してみる。 これは 2021 年の第 01 週を表している。

$ python -m luigi \
    --local-scheduler \
    --module example \
    ExampleTask \
    --dt-interval "2021-W01"

... (snip) ...

Processing: 2021-01-04
Processing: 2021-01-05
Processing: 2021-01-06
Processing: 2021-01-07
Processing: 2021-01-08
Processing: 2021-01-09
Processing: 2021-01-10

... (snip)

上記を見ると 2021-01-04 から 2021-01-10 が、指定した 2021-W01 に含まれる日付ということがわかる。

同様に "2021-01" という文字列を渡してみよう。 これは 2021 年の 1 月を表している。

$ python -m luigi \
    --local-scheduler \
    --module example \
    ExampleTask \
    --dt-interval "2021-01"

... (snip) ...

Processing: 2021-01-01
Processing: 2021-01-02
Processing: 2021-01-03
... (snip) ...
Processing: 2021-01-29
Processing: 2021-01-30
Processing: 2021-01-31

... (snip)

数が多いので省略しているけど、2021-01-01 から 2021-01-31 が範囲に含まれることがわかる。

また、ISO 8601 形式の日付をハイフン (-) でつなぐと、任意の日付の範囲が指定できる。 たとえば 2021-09-01 から 2021-09-07 を指定してみよう。

$ python -m luigi \
    --local-scheduler \
    --module example \
    ExampleTask \
    --dt-interval "2021-09-01-2021-09-07"

... (snip) ...

Processing: 2021-09-01
Processing: 2021-09-02
Processing: 2021-09-03
Processing: 2021-09-04
Processing: 2021-09-05
Processing: 2021-09-06

... (snip)

末尾の日付は含まれずに、2021-09-01 から 2021-09-06 が範囲に含まれていることがわかる。

このように DateIntervalParameter を使うと、特定の日付の範囲を受け取る処理が書きやすい。 典型的には、開始日と終了日を個別に取っていたような処理を置き換えることができる。

動作原理

使い方はわかったので、この DateIntervalParameter というパラメータが、どのように実現されているのか見ていこう。

以下のサンプルコードでは、受け取った dt_interval の型を表示している。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-


import luigi


class ExampleTask(luigi.Task):
    dt_interval = luigi.DateIntervalParameter()

    def run(self):
        # dt_interval の型を表示する
        print(f'*** Type of dt_interval: {type(self.dt_interval)} ***')

    def complete(self):
        return False

上記に "2021-W01" という文字列を渡すと luigi.date_interval.Week という型になっていることが確認できる。

$ python -m luigi \
    --local-scheduler \
    --module example \
    ExampleTask \
    --dt-interval "2021-W01"

... (snip) ...

*** Type of dt_interval: <class 'luigi.date_interval.Week'> ***

... (snip)

同様に、"2021-01" を渡したときでは luigi.date_interval.Month になる。

$ python -m luigi \
    --local-scheduler \
    --module example \
    ExampleTask \
    --dt-interval "2021-01"

... (snip) ...

*** Type of dt_interval: <class 'luigi.date_interval.Month'> ***

... (snip)

以下、実行については省略しつつ、以下のように対応している。

  • <年-月-日>
    • luigi.date_interval.Date
  • <年-W週>
    • luigi.date_interval.Week
  • <年-月>
    • luigi.date_interval.Month
  • <年-月-日>-<年-月-日>
    • luigi.date_interval.Custom

ここからは Python のインタプリタを使って確認していこう。

$ python

luigi.date_interval をインポートする。

>>> from luigi import date_interval

たとえば luigi.date_interval.Week をインスタンス化してみよう。 このクラスには年と週数を渡す必要がある。

>>> from pprint import pprint
>>> week_interval = date_interval.Week(2021, 1)

ちなみに、実行時と同じように文字列を使ってインスタンス化するときは parse() メソッドを使えば良い。

>>> date_interval.Week.parse('2021-W01')
2021-W01

このオブジェクトには、先ほどのサンプルコードでも登場したように dates() というメソッドがある。 このメソッドは、指定された期間に含まれる日付を返す。

>>> pprint(week_interval.dates())
[datetime.date(2021, 1, 4),
 datetime.date(2021, 1, 5),
 datetime.date(2021, 1, 6),
 datetime.date(2021, 1, 7),
 datetime.date(2021, 1, 8),
 datetime.date(2021, 1, 9),
 datetime.date(2021, 1, 10)]

また、next() メソッドを使うと次の期間が、prev() メソッドを使うと前の期間が得られる。

>>> week_interval.next()
2021-W02
>>> week_interval.prev()
2020-W53

これらのメソッドは、Week 以外にも MonthCustom などでそれぞれ実装されている。

ちなみに DateIntervalParameter 自体は、受け取った文字列をそれぞれのクラスの parse() に順番に渡す実装になっている。

luigi.readthedocs.io

いじょう。

Python: Luigi の RangeDaily 系の使い方と注意点について

Python の Luigi はバッチ処理に特化したデータパイプライン構築用のフレームワーク。 バッチ処理に特化しているとあって、定期的に実行する系のユーティリティも色々と用意されている。 今回は、その中でも特定の期間に実行すべきバッチ処理をまとめて扱うことのできる、RangeDaily を代表としたクラス群について書いてみる。

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

$ sw_vers
ProductName:    macOS
ProductVersion: 11.6
BuildVersion:   20G165
$ python -V
Python 3.9.7
$ pip list | grep -i luigi    
luigi                    3.0.3

もくじ

下準備

まずは Luigi をインストールしておく。

$ pip install luigi

RangeDaily について

RangeDaily は、日付を受け取って何らかの処理をする Task をまとめて扱うことのできるラッパータスク。 これを使うと、一日ずつ手動でタスクを実行する代わりに、開始期間 (と終了期間) を指定して一気に処理が実行できる。 とはいえ、文章で説明してもあんまりイメージできないと思うのでサンプルコードを使って説明していく。

以下のサンプルコードでは MyDailyTask というタスクを定義している。 このタスクは date というパラメータ名で日付を受け取る。 実行すると、/tmp 以下に受け取った日付を名前に含んだファイルを作成する。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import luigi


class MyDailyTask(luigi.Task):
    """日付を受け取って、名前に日付を含むファイルを生成するタスク"""
    date = luigi.DateParameter()

    def run(self):
        with self.output().open(mode='w') as fp:
            print('Hello, World!', file=fp)

    def output(self):
        path = '/tmp/luigi-{date:%Y-%m-%d}'.format(date=self.date)
        return luigi.LocalTarget(path)

上記を example.py という名前で保存しよう。

通常なら、上記のタスクを実行するには次のようにする。 Luigi のスケジューラに、タスクを表したクラスと、実行したい日付を --date パラメータで指定する。

$ python -m luigi \
    --local-scheduler \
    --module example \
    MyDailyTask \
    --date 2021-09-01

... (snip) ...

===== Luigi Execution Summary =====

Scheduled 1 tasks of which:
* 1 ran successfully:
    - 1 MyDailyTask(date=2021-09-01)

This progress looks :) because there were no failed tasks or missing dependencies

===== Luigi Execution Summary =====

実行すると、次のようにファイルが作られる。

$ ls /tmp/luigi-2021-09-01  
/tmp/luigi-2021-09-01
$ cat /tmp/luigi-2021-09-01 
Hello, World!

上記は一日ずつ処理する場合の例になる。

続いては、今回の主題である RangeDaily を使ってみよう。 RangeDaily を使うと、この日付以降をまとめて実行する、といったことができる。 以下は --start オプションを使って 2021-09-01 以降を一気に実行する場合の例。 実行するタスクとしては RangeDaily を指定して、--of パラメータで自分で定義したタスクを指定する。

$ python -m luigi \
    --local-scheduler \
    RangeDaily \
    --module example \
    --of MyDailyTask \
    --start 2021-09-01

... (snip) ...

===== Luigi Execution Summary =====

Scheduled 19 tasks of which:
* 19 ran successfully:
    - 18 MyDailyTask(date=2021-09-02...2021-09-19)
    - 1 RangeDaily(...)

This progress looks :) because there were no failed tasks or missing dependencies

===== Luigi Execution Summary =====

上記の実行結果を見ると、指定した日付以降を一括で実行できていることがわかる。 なお、これを実行しているシステムの日付は 2021-09-19 である。

$ date "+%Y-%m-%d"
2021-09-19

ディレクトリを確認すると 2021-09-01 から 2021-09-19 の範囲でファイルができている。 なお、2021-09-01 については最初に単発で実行したもの。

$ ls /tmp/luigi-* | head -n 1
/tmp/luigi-2021-09-01
$ ls /tmp/luigi-* | tail -n 1
/tmp/luigi-2021-09-19

試しにもう一度同じコマンドを実行してみよう。 すると、いずれのタスクも実行されないことがわかる。 これは、既にタスクが成果物としているファイルが存在するため。

$ python -m luigi \
    --local-scheduler \
    RangeDaily \
    --module example \
    --of MyDailyTask \
    --start 2021-09-01

... (snip) ...

===== Luigi Execution Summary =====

Scheduled 1 tasks of which:
* 1 complete ones were encountered:
    - 1 RangeDaily(...)

Did not run any tasks
This progress looks :) because there were no failed tasks or missing dependencies

===== Luigi Execution Summary =====

この特性はバッチ処理を扱う上でなかなか便利だったりする。 というのも、何らかの理由で成果物のファイルが日付として歯抜けになっている場合、自動で存在しない日付を検出して実行してくれる。 また、日付の最後も特に指定しない限り今日になるので、バッチ処理として仕込むコマンドに RangeDaily を指定することもできる。

注意点について

続いては RangeDaily の注意点について。 端的に言うと、現時刻から遡れる日数とタスク数に上限が設定されている。

たとえば開始の日付として年初を指定して実行してみよう。

$ python -m luigi \
    --local-scheduler \
    RangeDaily \
    --module example \
    --of MyDailyTask \
    --start 2021-01-01

... (snip) ...

===== Luigi Execution Summary =====

Scheduled 51 tasks of which:
* 51 ran successfully:
    - 50 MyDailyTask(date=2021-06-12...2021-07-31)
    - 1 RangeDaily(...)

This progress looks :) because there were no failed tasks or missing dependencies

===== Luigi Execution Summary =====

上記の実行結果 (Luigi Execution Summary) を見て違和感を覚えたかもしれない。 というのも、作成されたファイルの日付が 2021-06-12 から始まっているため。

$ ls /tmp/luigi-* | sort | head -n 5
/tmp/luigi-2021-06-12
/tmp/luigi-2021-06-13
/tmp/luigi-2021-06-14
/tmp/luigi-2021-06-15
/tmp/luigi-2021-06-16

また、末尾についても 2021-07-31 で終わっている。

$ ls /tmp/luigi-* | sort | tail -n 25 | head -n 10
/tmp/luigi-2021-07-26
/tmp/luigi-2021-07-27
/tmp/luigi-2021-07-28
/tmp/luigi-2021-07-29
/tmp/luigi-2021-07-30
/tmp/luigi-2021-07-31
/tmp/luigi-2021-09-01
/tmp/luigi-2021-09-02
/tmp/luigi-2021-09-03
/tmp/luigi-2021-09-04

本来なら 2021-01-01 から歯抜けなくファイルができてほしかった。 どうして、こんなことが起こるのか。

この問題はドキュメントを見るとわかる。

luigi.readthedocs.io

遡れる日数の上限 (--days-back)

まず、RangeDailyBase という、RangeDaily の基底クラスには days_back というパラメータがある。 このパラメータは、現在の日時から遡る日付の上限を定めていて、デフォルトで 100 に設定されている。

確認すると、今日から 100 日前は 2021-06-11 だった。 つまり、その日付を含む過去は遡って実行されないようになっている。

$ brew install coreutils
$ gdate --iso-8601 --date '100 days ago'
2021-06-11

試しに --days-back パラメータに 365 を指定して、上限を 1 年まで広げてみよう。

$ python -m luigi \
    --local-scheduler \
    RangeDaily \
    --module example \
    --of MyDailyTask \
    --start 2021-01-01 \
    --days-back 365

... (snip) ...

===== Luigi Execution Summary =====

Scheduled 51 tasks of which:
* 51 ran successfully:
    - 50 MyDailyTask(date=2021-01-01...2021-02-19)
    - 1 RangeDaily(...)

This progress looks :) because there were no failed tasks or missing dependencies

===== Luigi Execution Summary =====

すると、上記を見てわかるとおり年初から実行されるようになった。

実行できるタスク数の上限 (--task-limit)

同様に、一度に実行できるタスクの数にも上限がある。 こちらは --task-limit というパラメータで指定できる。 デフォルトでは 50 に設定されている。

試しに、タスク数の上限も 365 に引きあげて実行してみよう。

$ python -m luigi \
    --local-scheduler \
    RangeDaily \
    --module example \
    --of MyDailyTask \
    --start 2021-01-01 \
    --days-back 365 \
    --task-limit 365

... (snip) ...

===== Luigi Execution Summary =====

Scheduled 144 tasks of which:
* 144 ran successfully:
    - 143 MyDailyTask(date=2021-02-20,2021-02-21,2021-02-22,...)
    - 1 RangeDaily(...)

This progress looks :) because there were no failed tasks or missing dependencies

===== Luigi Execution Summary =====

上記を見てわかるとおり、これまでの上限だった 50 を越えてタスクが実行されている。

ファイルの数を検算しても、年初から今日までの日数と一致した。

$ ls /tmp/luigi-* | wc -l
     262
$ gdate --iso-8601 --date '2021-01-01 261 days'
2021-09-19

確認がおわったら、一旦作られたファイルをすべて削除しておこう。

$ rm /tmp/luigi-*

一ヶ月毎の処理 (RangeMonthly)

ちなみに、タイトルに RangeDaily と書いたように、日次のバッチ以外を扱うためのクラスも用意されている。

以下のサンプルコードでは特定の月に対して実行することを想定した MyMonthlyTask というタスクを定義している。 パラメータの名前は date だけど、型が luigi.DateParameter ではなく luigi.MonthParameter になっている。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import luigi


class MyMonthlyTask(luigi.Task):
    """月を受け取って、名前に月を含むファイルを生成するタスク"""
    date = luigi.MonthParameter()

    def run(self):
        with self.output().open(mode='w') as fp:
            print('Hello, World!', file=fp)

    def output(self):
        path = '/tmp/luigi-{month:%Y-%m}'.format(month=self.date)
        return luigi.LocalTarget(path)

先ほどと同じように example.py という名前で保存しておこう。

上記を RangeMonthly 経由で実行する。

$ python -m luigi \
    --local-scheduler \
    RangeMonthly \
    --module example \
    --of MyMonthlyTask \
    --start 2021-01

... (snip) ...

===== Luigi Execution Summary =====

Scheduled 9 tasks of which:
* 9 ran successfully:
    - 8 MyMonthlyTask(date=2021-01...2021-08)
    - 1 RangeMonthly(...)

This progress looks :) because there were no failed tasks or missing dependencies

===== Luigi Execution Summary =====

上記を見ると、今月を含まない形でタスクが実行されていることがわかる。 これは典型的には日付が揃わないうちに集計処理を実行することは少ないことが関係しているんだろう。

先ほどと同じように、確認できたらファイルを一旦きれいにしておく。

$ rm /tmp/luigi-*

一時間毎の処理 (RangeHourly)

同じように一時間毎の処理も扱うことができる。 以下では一時間毎に実行することを期待した処理を MyHourlyTask という名前で定義している。 パラメータの名前は date だけど、クラスは luigi.DateHourParameter を用いる。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import luigi


class MyHourlyTask(luigi.Task):
    """時間 (Hour) を受け取って、名前に時間を含むファイルを生成するタスク"""
    date = luigi.DateHourParameter()

    def run(self):
        with self.output().open(mode='w') as fp:
            print('Hello, World!', file=fp)

    def output(self):
        path = '/tmp/luigi-{month:%Y-%m-%dT%H}'.format(month=self.date)
        return luigi.LocalTarget(path)

一時間ごとの処理になるとファイルが増えるので、今回は明示的に終了の時刻も指定しておこう。 以下では --start2021-09-15T00 を、--stop2021-09-16T12 を指定している。 T 以降が時間を表している。

$ python -m luigi \
    --local-scheduler \
    RangeHourly \
    --module example \
    --of MyHourlyTask \
    --start 2021-09-15T00 \
    --stop 2021-09-16T12

... (snip) ...

===== Luigi Execution Summary =====

Scheduled 37 tasks of which:
* 37 ran successfully:
    - 36 MyHourlyTask(date=2021-09-15T00...2021-09-16T11)
    - 1 RangeHourly(...)

This progress looks :) because there were no failed tasks or missing dependencies

===== Luigi Execution Summary =====

結果を見てわかるとおり、--start の時刻を含んで --stop を含まない形でタスクが実行されていることがわかる。

$ ls /tmp/luigi-*  
/tmp/luigi-2021-09-15T00    /tmp/luigi-2021-09-15T12    /tmp/luigi-2021-09-16T00
/tmp/luigi-2021-09-15T01    /tmp/luigi-2021-09-15T13    /tmp/luigi-2021-09-16T01
/tmp/luigi-2021-09-15T02    /tmp/luigi-2021-09-15T14    /tmp/luigi-2021-09-16T02
/tmp/luigi-2021-09-15T03    /tmp/luigi-2021-09-15T15    /tmp/luigi-2021-09-16T03
/tmp/luigi-2021-09-15T04    /tmp/luigi-2021-09-15T16    /tmp/luigi-2021-09-16T04
/tmp/luigi-2021-09-15T05    /tmp/luigi-2021-09-15T17    /tmp/luigi-2021-09-16T05
/tmp/luigi-2021-09-15T06    /tmp/luigi-2021-09-15T18    /tmp/luigi-2021-09-16T06
/tmp/luigi-2021-09-15T07    /tmp/luigi-2021-09-15T19    /tmp/luigi-2021-09-16T07
/tmp/luigi-2021-09-15T08    /tmp/luigi-2021-09-15T20    /tmp/luigi-2021-09-16T08
/tmp/luigi-2021-09-15T09    /tmp/luigi-2021-09-15T21    /tmp/luigi-2021-09-16T09
/tmp/luigi-2021-09-15T10    /tmp/luigi-2021-09-15T22    /tmp/luigi-2021-09-16T10
/tmp/luigi-2021-09-15T11    /tmp/luigi-2021-09-15T23    /tmp/luigi-2021-09-16T11

まとめ

今回は Luigi で特定の期間毎に実行するバッチ処理を、まとめて扱う RangeDaily 系の使い方と注意点について紹介した。 注意点としては、遡る日付や実行するタスクの数に上限がある点や、期間によって開始・終了を含む・含まないが微妙に異なる点が挙げられる。