CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: Polars で行・列が省略されないようにする

今回は Python のデータフレームライブラリの Polars で、データフレームを表示するときに行と列が省略されないようにする方法について。 結論から先に述べると、省略したくないときは pl.Config.set_tbl_cols()pl.Config.set_tbl_rows() に負の整数 (たとえば -1) を指定すれば良い。 また、正の整数を指定すれば、その数までは省略されないように振る舞う。

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

$ sw_vers
ProductName:    macOS
ProductVersion: 12.6.2
BuildVersion:   21G320
$ uname -srm
Darwin 21.6.0 arm64
$ python -V
Python 3.10.9
$ python -m pip list | grep polars
polars            0.15.16

もくじ

下準備

サンプルデータとして Diamonds データセットの CSV ファイルをダウンロードしておく。

$ wget https://raw.githubusercontent.com/mwaskom/seaborn-data/master/diamonds.csv

そして、肝心の Polars をインストールする。

$ pip install polars

インストールできたら Python のインタプリタを起動する。

$ python

Polars をインポートして、先ほどダウンロードした CSV ファイルからデータフレームを生成する。

>>> import polars as pl
>>> df = pl.read_csv("diamonds.csv")

列が省略されないようにする

現在のバージョンでは、デフォルトで省略されずに表示される列は 8 列まで。 データフレームにそれ以上の列があるときは、表示するときに中間の列が「...」と省略される。

>>> df.head()
shape: (5, 10)
┌───────┬─────────┬───────┬─────────┬─────┬───────┬──────┬──────┬──────┐
│ carat ┆ cut     ┆ color ┆ clarity ┆ ... ┆ price ┆ x    ┆ y    ┆ z    │
│ ---   ┆ ---     ┆ ---   ┆ ---     ┆     ┆ ---   ┆ ---  ┆ ---  ┆ ---  │
│ f64   ┆ strstrstr     ┆     ┆ i64   ┆ f64  ┆ f64  ┆ f64  │
╞═══════╪═════════╪═══════╪═════════╪═════╪═══════╪══════╪══════╪══════╡
│ 0.23  ┆ Ideal   ┆ E     ┆ SI2     ┆ ... ┆ 3263.953.982.43 │
│ 0.21  ┆ Premium ┆ E     ┆ SI1     ┆ ... ┆ 3263.893.842.31 │
│ 0.23  ┆ Good    ┆ E     ┆ VS1     ┆ ... ┆ 3274.054.072.31 │
│ 0.29  ┆ Premium ┆ I     ┆ VS2     ┆ ... ┆ 3344.24.232.63 │
│ 0.31  ┆ Good    ┆ J     ┆ SI2     ┆ ... ┆ 3354.344.352.75 │
└───────┴─────────┴───────┴─────────┴─────┴───────┴──────┴──────┴──────┘

まったく省略してほしくないときは pl.Config.set_tbl_cols() に負の整数を指定する。

>>> pl.Config.set_tbl_cols(-1)
<class 'polars.cfg.Config'>

すると、次のように何列あっても省略されなくなる。

>>> df.head()
shape: (5, 10)
┌───────┬─────────┬───────┬─────────┬───────┬───────┬───────┬──────┬──────┬──────┐
│ carat ┆ cut     ┆ color ┆ clarity ┆ depth ┆ table ┆ price ┆ x    ┆ y    ┆ z    │
│ ---   ┆ ---     ┆ ---   ┆ ---     ┆ ---   ┆ ---   ┆ ---   ┆ ---  ┆ ---  ┆ ---  │
│ f64   ┆ strstrstr     ┆ f64   ┆ f64   ┆ i64   ┆ f64  ┆ f64  ┆ f64  │
╞═══════╪═════════╪═══════╪═════════╪═══════╪═══════╪═══════╪══════╪══════╪══════╡
│ 0.23  ┆ Ideal   ┆ E     ┆ SI2     ┆ 61.555.03263.953.982.43 │
│ 0.21  ┆ Premium ┆ E     ┆ SI1     ┆ 59.861.03263.893.842.31 │
│ 0.23  ┆ Good    ┆ E     ┆ VS1     ┆ 56.965.03274.054.072.31 │
│ 0.29  ┆ Premium ┆ I     ┆ VS2     ┆ 62.458.03344.24.232.63 │
│ 0.31  ┆ Good    ┆ J     ┆ SI2     ┆ 63.358.03354.344.352.75 │
└───────┴─────────┴───────┴─────────┴───────┴───────┴───────┴──────┴──────┴──────┘

正の整数を指定すると、その列数まで省略されないように振る舞う。 試しに 2 を指定してみよう。

>>> pl.Config.set_tbl_cols(2)

次のように 2 列だけ省略されずに表示されるようになった。

>>> df.head()
shape: (5, 10)
┌───────┬─────┬──────┐
│ carat ┆ ... ┆ z    │
│ ---   ┆     ┆ ---  │
│ f64   ┆     ┆ f64  │
╞═══════╪═════╪══════╡
│ 0.23  ┆ ... ┆ 2.43 │
│ 0.21  ┆ ... ┆ 2.31 │
│ 0.23  ┆ ... ┆ 2.31 │
│ 0.29  ┆ ... ┆ 2.63 │
│ 0.31  ┆ ... ┆ 2.75 │
└───────┴─────┴──────┘

行が省略されないようにする

同様に、行が省略されないようにしてみよう。 現在のバージョンのデフォルトでは 8 行まで省略されずに表示される。

>>> df.head(10)
shape: (10, 10)
┌───────┬─────┬──────┐
│ carat ┆ ... ┆ z    │
│ ---   ┆     ┆ ---  │
│ f64   ┆     ┆ f64  │
╞═══════╪═════╪══════╡
│ 0.23  ┆ ... ┆ 2.43 │
│ 0.21  ┆ ... ┆ 2.31 │
│ 0.23  ┆ ... ┆ 2.31 │
│ 0.29  ┆ ... ┆ 2.63 │
│ ...   ┆ ... ┆ ...  │
│ 0.24  ┆ ... ┆ 2.47 │
│ 0.26  ┆ ... ┆ 2.53 │
│ 0.22  ┆ ... ┆ 2.49 │
│ 0.23  ┆ ... ┆ 2.39 │
└───────┴─────┴──────┘

行をまったく省略したくないときは pl.Config.set_tbl_rows() に負の整数を指定すれば良い。

>>> pl.Config.set_tbl_rows(-1)
<class 'polars.cfg.Config'>

これで行が省略されなくなる。

>>> df.head(10)
shape: (10, 10)
┌───────┬─────┬──────┐
│ carat ┆ ... ┆ z    │
│ ---   ┆     ┆ ---  │
│ f64   ┆     ┆ f64  │
╞═══════╪═════╪══════╡
│ 0.23  ┆ ... ┆ 2.43 │
│ 0.21  ┆ ... ┆ 2.31 │
│ 0.23  ┆ ... ┆ 2.31 │
│ 0.29  ┆ ... ┆ 2.63 │
│ 0.31  ┆ ... ┆ 2.75 │
│ 0.24  ┆ ... ┆ 2.48 │
│ 0.24  ┆ ... ┆ 2.47 │
│ 0.26  ┆ ... ┆ 2.53 │
│ 0.22  ┆ ... ┆ 2.49 │
│ 0.23  ┆ ... ┆ 2.39 │
└───────┴─────┴──────┘

明示的に省略されるまでの行数を指定したいときは、先ほどと同じように正の整数を指定する。

>>> pl.Config.set_tbl_rows(2)
<class 'polars.cfg.Config'>
>>> df.head(10)
shape: (10, 10)
┌───────┬─────┬──────┐
│ carat ┆ ... ┆ z    │
│ ---   ┆     ┆ ---  │
│ f64   ┆     ┆ f64  │
╞═══════╪═════╪══════╡
│ 0.23  ┆ ... ┆ 2.43 │
│ ...   ┆ ... ┆ ...  │
│ 0.23  ┆ ... ┆ 2.39 │
└───────┴─────┴──────┘

いじょう。

参考

pola-rs.github.io

pola-rs.github.io

OpenSSH で過去に作った鍵の種類や長さを確かめる

今回は OpenSSH で過去に作った鍵の種類や長さを確認する方法について。 結論から先に述べると ssh-keygen(1) の -l オプションと -f オプションを組み合わせれば良い。

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

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=22.04
DISTRIB_CODENAME=jammy
DISTRIB_DESCRIPTION="Ubuntu 22.04.1 LTS"
$ uname -srm
Linux 5.15.0-58-generic x86_64
$ ssh -V
OpenSSH_8.9p1 Ubuntu-3ubuntu0.1, OpenSSL 3.0.2 15 Mar 2022

もくじ

下準備

あらかじめ OpenSSH のクライアントをインストールしておく。

$ sudo apt-get update
$ sudo apt-get -y install openssh-client

動作確認用の公開鍵ペアを生成する

動作確認用に RSA で公開鍵のペアを作っておく。 パスフレーズは空で、鍵の名前は id_rsa にした。

$ ssh-keygen -t rsa -P '' -f ~/.ssh/id_rsa
Generating public/private rsa key pair.
Your identification has been saved in /home/ubuntu/.ssh/id_rsa
Your public key has been saved in /home/ubuntu/.ssh/id_rsa.pub
The key fingerprint is:
SHA256:FPI5/OZLR94xU83YcUQ82sD7k6ckRqh7bL67tGXcL2U ubuntu@jammy
The key's randomart image is:
+---[RSA 3072]----+
|      . .   .  =+|
|       + o   o ==|
|        *  .  * *|
|       . o. .o o |
|        S.o.. = .|
|        .o +oo.BE|
|         o+.*oo+o|
|        .o+*  o. |
|         +B+   ..|
+----[SHA256]-----+

上記から、どうやら 3072 ビットの RSA で公開鍵ペアが生成されたようだ。

鍵の種類や長さを確認する

鍵の種類や長さを確認するには ssh-keygen(1) の -l オプションと -f オプションを組み合わせて使う。 -l オプションを使うことで公開鍵のフィンガープリントと付随する情報を出力するモードになる。 -f オプションでは鍵のファイルパスを指定する。

$ ssh-keygen -l -f ~/.ssh/id_rsa
3072 SHA256:FPI5/OZLR94xU83YcUQ82sD7k6ckRqh7bL67tGXcL2U ubuntu@jammy (RSA)

上記から、この鍵では 3072 ビットの RSA を使っていることが確認できる。

上記では秘密鍵の方を指定していたけど、もちろん公開鍵の方を指定しても問題ない。

$ ssh-keygen -l -f ~/.ssh/id_rsa.pub
3072 SHA256:FPI5/OZLR94xU83YcUQ82sD7k6ckRqh7bL67tGXcL2U ubuntu@jammy (RSA)

別の鍵の種類でも確認する

念の為、別の鍵の種類でも試しておく。 ここでは ED25519 を使った。

$ ssh-keygen -t ed25519 -P '' -f ~/.ssh/id_ed25519
Generating public/private ed25519 key pair.
Your identification has been saved in /home/ubuntu/.ssh/id_ed25519
Your public key has been saved in /home/ubuntu/.ssh/id_ed25519.pub
The key fingerprint is:
SHA256:pmGTVjlzXSv1vJLpwNiRpCF4Q62hyXpys3qPWyrE2lM ubuntu@jammy
The key's randomart image is:
+--[ED25519 256]--+
|      oo.. .  o  |
|     . +.o+..o + |
|    . + O..oo . o|
|     + + ++ ..o .|
|  . . * S. + + . |
|   = E =    o .  |
|  + = +.     .   |
| . + o+          |
|   .=+o.         |
+----[SHA256]-----+

上記から、どうやら 256 ビットの ED25519 の公開鍵ペアが生成されたことが確認できる。 なお、ED25519 はビット長が 256 で固定らしい。

先ほどと同じように ssh-keygen(1) で確認する。

$ ssh-keygen -l -f ~/.ssh/id_ed25519.pub 
256 SHA256:pmGTVjlzXSv1vJLpwNiRpCF4Q62hyXpys3qPWyrE2lM ubuntu@jammy (ED25519)
$ ssh-keygen -l -f ~/.ssh/id_ed25519
256 SHA256:pmGTVjlzXSv1vJLpwNiRpCF4Q62hyXpys3qPWyrE2lM ubuntu@jammy (ED25519)

ちゃんと 256 ビットの ED25519 であることが確認できた。

いじょう。

2023-01-24 追記

フィンガープリントを計算する際のハッシュ形式と署名で用いられるハッシュ形式を混同している点をご指摘いただいたため修正しました。 ありがとうございます。

Target Encoding のスムージングについて

Target (Mean) Encoding の出典は、2001 年の ACM SIGKDD Explorations Newsletter, Volume 3, Issue 1 に掲載された以下の論文らしい。

https://dl.acm.org/doi/10.1145/507533.507538

この論文には Target Encoding のスムージングに関する詳しい記述があった。 そこで、その内容を元に巷のフレームワークがどのようにスムージングを実装しているかを併せて調査してみた。 自分用のメモも兼ねて、ここに書き残すことにする。

なお、Target Encoding 自体の説明については以下を参照のこと。

blog.amedama.jp

もくじ

なぜスムージングするのか

そもそも、なぜスムージングが必要になるのか。 それは、スムージングのない Target Encoding は、少数のサンプルしか含まれないカテゴリにおいて極端な値を取りやすいため。

たとえば、二値分類のタスクを題材にして考えてみよう。 極端なケースとして、学習データの中にサンプルが 1 つしか含まれないカテゴリがあった場合の計算について考える。 ただし、単純のため Target Leakage を軽減するための Out-of-fold (OOF) での計算は考えない。 この場合、スムージングのないナイーブは Target Encoding の計算は論文の (2) 式に対応する。

 S_i = \frac{ n_iY }{ n_i }

上記において  S_{i} が、あるカテゴリ  i の Target Statistics になる。  n_i はカテゴリ  i に属するサンプル数を表す。  n_iY はカテゴリ  i に属する  Y = 1 のサンプル数を表している。

もし、1 つしかないサンプルの目的変数が 1 だとすると、Target Statistics は  S_i = \frac{1}{1} = 1 になる。 では、このカテゴリについて未知のテストデータにおいても Target Statistics が 1 と信じることは妥当だろうか。 直感的には、サンプル数が不足していることから信頼できないと考えるはず。

このような値をモデルが信用して学習に利用してしまうと、過学習を引き起こす恐れ 1 がある。

Empirical Bayesian (EB) を用いたスムージング

前述した問題を緩和するためにスムージングを導入する。 やりたいこととしては、少数のサンプルしか含まれないカテゴリにおいても極端な値を取らないようにしたい。 これには、学習データ全体で計算した目的変数の平均を計算結果に「ブレンド」する。 論文で最初に示されている方法は以下で、これは (3) 式に対応する。 なお、この式は Empirical Bayesian という手法に由来するらしい。

 S_i = \lambda (n_i) \frac{ n_iY }{ n_i } + (1 - \lambda (n_i) ) \frac{ n_Y }{ n_{TR} }

上記は、第 1 項があるカテゴリ  i について計算した値で、第 2 項が学習データ全体について計算した値になる。 第 2 項に登場する  n_{TR} は学習データに含まれるサンプル数で、 n_Y が学習データの中で  Y = 1 なサンプル数を表している。 そして、 \lambda (n_i) がスムージングの強度を決める (出力は 0 ~ 1)。 もし  \lambda (n_i) = 0 なら完全に学習データ全体の平均になり、 \lambda (n_i) = 1 なら完全にカテゴリ  i の平均になる。

 \lambda (n_i) の計算方法については、以下が例として挙げられている。 これは論文において (4) 式に対応する。

 \lambda (n) = \frac{1}{1 + e^{\frac{n - k}{f}} }

上記で  k  f は特性を決めるハイパーパラメータとなる。 つまり、対象のデータと扱うモデルごとに最適な値が存在する。

category_encoders の実装

この Empirical Bayesian に由来するスムージングは category_encoders の TargetEncoder が採用している。

ソースコードにおいて、以下が (4) 式に対応する。

github.com

そして、(4) 式で求めたスムージングの強度を、以下の (3) 式に対応する箇所で使っている。

github.com

m-probability estimate を用いたスムージング

論文では、もうひとつ簡略化したスムージングの手法が紹介されている。 これは Additive Smoothing の一種で、m-probability estimate と呼ばれるやり方らしい。 以下は論文の (7) 式に対応する

 p^*_c = \frac{ n_c + p_c m }{ n + m }

上記で、 p^*_c が、あるカテゴリ  c の Target Statistics になる。  n は、あるカテゴリに属するサンプル数を表す。  n_c は、あるカテゴリ  c に属する  Y = 1 なサンプル数を表す。  m は、スムージングの強度を決めるハイパーパラメータを表す (0 ~)。  m = 0 のときスムージングしないことになる。  p_c は、あるカテゴリ  c の事前確率を表す。 典型的には全カテゴリの平均的な Target Statistics を使えば良さそうだ。

category_encoders の実装

この m-probability estimate を用いたスムージングは category_encoders の MEstimateEncoder が採用している。

ソースコードにおいて、以下が (7) 式に対応する。

github.com

cuML の実装

同様に、cuML の TargetEncoder もスムージングに m-probability estimate を採用している。

ソースコードにおいて、以下が (7) 式に対応する。

github.com

まとめ

今回は Target Encoding の出典となる論文を読んで、スムージングの手法について調べた。 また、各フレームワークがどのようなスムージングを導入しているのか実装を調べた。


  1. 実際に問題となるかは対象のデータや扱うモデルの特性に依存する

Python: PyTorch でバックプロパゲーションが上手くいかない場所を自動で見つける

PyTorch を使っていると、はるか遠く離れた場所で計算した結果に nan や inf が含まれることで、思いもよらない場所から非直感的なエラーを生じることがある。 あるいは、自動微分したときにゼロ除算が生じるようなパターンでは、順伝搬の結果だけ見ていても原因にたどり着くことが難しい。 こういった問題は、デバッガなどを使って地道に原因を探ろうとすると多くの手間と時間がかかる。

そんな折、PyTorch にはそうした問題に対処する上で有益な機能があることを知った。 具体的には、以下の関数を使うと自動でバックプロパゲーションが上手くいかない箇所を見つけることができる。 今回は、この機能について書いてみる。

  • torch.autograd.set_detect_anomaly()
  • torch.autograd.detect_anomaly()

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

$ sw_vers
ProductName:    macOS
ProductVersion: 12.6.2
BuildVersion:   21G320
$ python -V
Python 3.10.9
$ pip list | grep -i torch
torch                        1.13.1

もくじ

下準備

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

$ pip install torch numpy

入力によって逆伝搬が上手くいかないコード (1)

例として RMSLE (Root Mean Squared Logarithmic Error) を計算する場合について考える。 以下のサンプルコードでは、RMSLE の計算を RMSLELoss というクラスで実装している。 このコードは入力によってはバックプロパゲーションが上手くいかない。 具体的には、入力されるモデルの予測 (y_pred) に -1 以下の値が含まれたとき torch.log1p() の返り値に inf が含まれる。

import torch
from torch import nn


class RMSLELoss(nn.Module):
    """Root Mean Squared Logarithmic Error"""

    def __init__(self):
        super().__init__()
        self.mse_loss = nn.MSELoss()

    def forward(self, y_pred, y_true):
        # ここで y_pred に -1 以下の値が含まれると順伝搬の返り値が -inf になる
        log_y_pred = torch.log1p(y_pred)
        log_y_true = torch.log1p(y_true)
        # 入力に -inf が含まれることで返り値が inf になってしまう
        msle = self.mse_loss(log_y_pred, log_y_true)
        rmsle_loss = torch.sqrt(msle)
        return rmsle_loss


def main():
    # モデルの出力に -1 以下の値が含まれるとする
    y_pred = torch.tensor([-1., 0., 1.], dtype=torch.float64, requires_grad=True)
    y_true = torch.tensor([2., 3., 4.], dtype=torch.float64, requires_grad=True)

    # 順伝搬
    loss_fn = RMSLELoss()
    out = loss_fn(y_pred, y_true)

    # 結果
    print(out)

    # 逆伝搬
    out.backward()

    # 勾配
    print(y_pred.grad)
    print(y_true.grad)


if __name__ == '__main__':
    main()

上記を実行してみよう。 入力に -1 以下の値が入ると、最終的な結果が inf になっている。 そして y_predy_true の勾配に nan が確認できる。

tensor(inf, dtype=torch.float64, grad_fn=<SqrtBackward0>)
tensor([nan, -0., -0.], dtype=torch.float64)
tensor([nan, 0., 0.], dtype=torch.float64)

上手くいかない箇所を自動で見つける

では、今回の主題となる torch.autograd.set_detect_anomaly() を使ってみよう。 この関数には、第一引数に真偽値のフラグを渡して機能の有効・無効を切り替える。 もちろんデフォルトでは機能は無効となっており、デバッグをするときだけ有効にすることが推奨されている。 これは、機能を有効にするとバックプロパゲーションにおいて値のチェックが逐一入ることによるオーバーヘッドが生じるため。

import torch
from torch import nn


class RMSLELoss(nn.Module):

    def __init__(self):
        super().__init__()
        self.mse_loss = nn.MSELoss()

    def forward(self, y_pred, y_true):
        log_y_pred = torch.log1p(y_pred)
        log_y_true = torch.log1p(y_true)
        msle = self.mse_loss(log_y_pred, log_y_true)
        rmsle_loss = torch.sqrt(msle)
        return rmsle_loss


def main():
    # 逆伝搬で nan になるケースを自動で見つける
    torch.autograd.set_detect_anomaly(True)

    y_pred = torch.tensor([-1., 0., 1.], dtype=torch.float64, requires_grad=True)
    y_true = torch.tensor([2., 3., 4.], dtype=torch.float64, requires_grad=True)

    loss_fn = RMSLELoss()
    out = loss_fn(y_pred, y_true)

    print(out)

    out.backward()

    print(y_pred.grad)
    print(y_true.grad)


if __name__ == '__main__':
    main()

上記を実行してみよう。 すると、MseLossBackward0 の結果において値に nan が含まれることが示されている。

$ python anodet.py       
tensor(inf, dtype=torch.float64, grad_fn=<SqrtBackward0>)
/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/autograd/__init__.py:197: UserWarning: Error detected in MseLossBackward0. Traceback of forward call that caused the error:
  File "/Users/amedama/Documents/temporary/anodet.py", line 35, in <module>
    main()
  File "/Users/amedama/Documents/temporary/anodet.py", line 27, in main
    out = loss_fn(y_pred, y_true)
  File "/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/nn/modules/module.py", line 1194, in _call_impl
    return forward_call(*input, **kwargs)
  File "/Users/amedama/Documents/temporary/anodet.py", line 14, in forward
    msle = self.mse_loss(log_y_pred, log_y_true)
  File "/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/nn/modules/module.py", line 1194, in _call_impl
    return forward_call(*input, **kwargs)
  File "/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/nn/modules/loss.py", line 536, in forward
    return F.mse_loss(input, target, reduction=self.reduction)
  File "/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/nn/functional.py", line 3292, in mse_loss
    return torch._C._nn.mse_loss(expanded_input, expanded_target, _Reduction.get_enum(reduction))
  File "/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/fx/traceback.py", line 57, in format_stack
    return traceback.format_stack()
 (Triggered internally at /Users/runner/work/pytorch/pytorch/pytorch/torch/csrc/autograd/python_anomaly_mode.cpp:119.)
  Variable._execution_engine.run_backward(  # Calls into the C++ engine to run the backward pass
Traceback (most recent call last):
  File "/Users/amedama/Documents/temporary/anodet.py", line 35, in <module>
    main()
  File "/Users/amedama/Documents/temporary/anodet.py", line 31, in main
    out.backward()
  File "/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/_tensor.py", line 488, in backward
    torch.autograd.backward(
  File "/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/autograd/__init__.py", line 197, in backward
    Variable._execution_engine.run_backward(  # Calls into the C++ engine to run the backward pass
RuntimeError: Function 'MseLossBackward0' returned nan values in its 0th output.

このように、バックプロパゲーションが上手くいかない場所を自動で検知できた。

問題を修正する (1)

では、問題を修正するため試しに y_pred の値の下限が 0 となるように torch.clamp() の処理を挟んでみよう。

import torch
from torch import nn


class RMSLELoss(nn.Module):

    def __init__(self):
        super().__init__()
        self.mse_loss = nn.MSELoss()

    def forward(self, y_pred, y_true):
        # 入力の下限を 0. に制限する
        clamped_y_pred = torch.clamp(y_pred, min=0.)
        log_y_pred = torch.log1p(clamped_y_pred)
        log_y_true = torch.log1p(y_true)
        msle = self.mse_loss(log_y_pred, log_y_true)
        rmsle_loss = torch.sqrt(msle)
        return rmsle_loss


def main():
    torch.autograd.set_detect_anomaly(True)

    y_pred = torch.tensor([-1., 0., 1.], dtype=torch.float64, requires_grad=True)
    y_true = torch.tensor([2., 3., 4.], dtype=torch.float64, requires_grad=True)

    loss_fn = RMSLELoss()
    out = loss_fn(y_pred, y_true)

    print(out)

    out.backward()


if __name__ == '__main__':
    main()

実行すると、今度は例外にならずに済んでいる。 y_predy_true の勾配にも nan は登場しない。

$ python anodet.py 
tensor(1.1501, dtype=torch.float64, grad_fn=<SqrtBackward0>)
tensor([0.0000, -0.4018, -0.1328], dtype=torch.float64)
tensor([0.1061, 0.1004, 0.0531], dtype=torch.float64)

入力によって逆伝搬が上手くいかないコード (2)

さて、これで万事解決かと思いきや、実はまだ問題が残っている。 損失がゼロになるときを考えると torch.sqrt() のバックプロパゲーションにおいてゼロ除算が生じるため。 これは順伝搬では値に nan や inf が登場しないことから問題に気づきにくそう。

import torch
from torch import nn


class RMSLELoss(nn.Module):

    def __init__(self):
        super().__init__()
        self.mse_loss = nn.MSELoss()

    def forward(self, y_pred, y_true):
        clamped_y_pred = torch.clamp(y_pred, min=0.)
        log_y_pred = torch.log1p(clamped_y_pred)
        log_y_true = torch.log1p(y_true)
        # 損失がゼロのときは...?
        msle = self.mse_loss(log_y_pred, log_y_true)
        rmsle_loss = torch.sqrt(msle)
        return rmsle_loss


def main():
    torch.autograd.set_detect_anomaly(True)

    # 損失がゼロになるパターンを考えてみると...?
    y_pred = torch.tensor([1., 2., 3.], dtype=torch.float64, requires_grad=True)
    y_true = torch.tensor([1., 2., 3.], dtype=torch.float64, requires_grad=True)

    loss_fn = RMSLELoss()
    out = loss_fn(y_pred, y_true)

    print(out)

    out.backward()

    print(y_pred.grad)
    print(y_true.grad)


if __name__ == '__main__':
    main()

実行すると、今度も MseLossBackward0 において結果に nan が含まれると指摘されている。

$ python anodet.py 
tensor(0., dtype=torch.float64, grad_fn=<SqrtBackward0>)
/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/autograd/__init__.py:197: UserWarning: Error detected in MseLossBackward0. Traceback of forward call that caused the error:
  File "/Users/amedama/Documents/temporary/anodet.py", line 36, in <module>
    main()
  File "/Users/amedama/Documents/temporary/anodet.py", line 28, in main
    out = loss_fn(y_pred, y_true)
  File "/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/nn/modules/module.py", line 1194, in _call_impl
    return forward_call(*input, **kwargs)
  File "/Users/amedama/Documents/temporary/anodet.py", line 15, in forward
    msle = self.mse_loss(log_y_pred, log_y_true)
  File "/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/nn/modules/module.py", line 1194, in _call_impl
    return forward_call(*input, **kwargs)
  File "/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/nn/modules/loss.py", line 536, in forward
    return F.mse_loss(input, target, reduction=self.reduction)
  File "/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/nn/functional.py", line 3292, in mse_loss
    return torch._C._nn.mse_loss(expanded_input, expanded_target, _Reduction.get_enum(reduction))
  File "/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/fx/traceback.py", line 57, in format_stack
    return traceback.format_stack()
 (Triggered internally at /Users/runner/work/pytorch/pytorch/pytorch/torch/csrc/autograd/python_anomaly_mode.cpp:119.)
  Variable._execution_engine.run_backward(  # Calls into the C++ engine to run the backward pass
Traceback (most recent call last):
  File "/Users/amedama/Documents/temporary/anodet.py", line 36, in <module>
    main()
  File "/Users/amedama/Documents/temporary/anodet.py", line 32, in main
    out.backward()
  File "/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/_tensor.py", line 488, in backward
    torch.autograd.backward(
  File "/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/autograd/__init__.py", line 197, in backward
    Variable._execution_engine.run_backward(  # Calls into the C++ engine to run the backward pass
RuntimeError: Function 'MseLossBackward0' returned nan values in its 0th output.

問題を修正する (2)

では、先ほどの問題を修正するために torch.sqrt() の計算に小さな値を足してみよう。

import torch
from torch import nn


class RMSLELoss(nn.Module):

    def __init__(self, epsilon=1e-5):
        super().__init__()
        self.mse_loss = nn.MSELoss()
        self.epsilon = epsilon

    def forward(self, y_pred, y_true):
        clamped_y_pred = torch.clamp(y_pred, min=0.)
        log_y_pred = torch.log1p(clamped_y_pred)
        log_y_true = torch.log1p(y_true)
        msle = self.mse_loss(log_y_pred, log_y_true)
        # ゼロ除算が生じないように小さな値を足す
        rmsle_loss = torch.sqrt(msle + self.epsilon)
        return rmsle_loss


def main():
    torch.autograd.set_detect_anomaly(True)

    y_pred = torch.tensor([1., 2., 3.], dtype=torch.float64, requires_grad=True)
    y_true = torch.tensor([1., 2., 3.], dtype=torch.float64, requires_grad=True)

    loss_fn = RMSLELoss()
    out = loss_fn(y_pred, y_true)

    print(out)

    out.backward()

    print(y_pred.grad)
    print(y_true.grad)


if __name__ == '__main__':
    main()

実行すると、今度は例外にならない。

$ python anodet.py
tensor(0.0032, dtype=torch.float64, grad_fn=<SqrtBackward0>)
tensor([0., 0., 0.], dtype=torch.float64)
tensor([0., 0., 0.], dtype=torch.float64)

特定のスコープでチェックする

ちなみに、特定のスコープでだけ backward() の結果をチェックしたいときは torch.autograd.detect_anomaly() が使える。 これはコンテキストマネージャになっているため、チェックしたい部分にだけ入れて使うことができる。

import torch
from torch import nn


class RMSLELoss(nn.Module):

    def __init__(self):
        super().__init__()
        self.mse_loss = nn.MSELoss()

    def forward(self, y_pred, y_true):
        log_y_pred = torch.log1p(y_pred)
        log_y_true = torch.log1p(y_true)
        msle = self.mse_loss(log_y_pred, log_y_true)
        rmsle_loss = torch.sqrt(msle)
        return rmsle_loss


def main():
    y_pred = torch.tensor([-1., 0., 1.], dtype=torch.float64, requires_grad=True)
    y_true = torch.tensor([2., 3., 4.], dtype=torch.float64, requires_grad=True)

    loss_fn = RMSLELoss()
    out = loss_fn(y_pred, y_true)

    print(out)

    # 特定のスコープでチェックする
    with torch.autograd.detect_anomaly():
        out.backward()


if __name__ == '__main__':
    main()

とはいえ、そんなに出番は無さそうかな。 また、トレースバックに含まれる情報も torch.autograd.set_detect_anomaly() より少なくなっているようだ。

$ python anodet.py 
tensor(inf, dtype=torch.float64, grad_fn=<SqrtBackward0>)
/Users/amedama/Documents/temporary/anodet.py:29: UserWarning: Anomaly Detection has been enabled. This mode will increase the runtime and should only be enabled for debugging.
  with torch.autograd.detect_anomaly():
/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/autograd/__init__.py:197: UserWarning: Error detected in MseLossBackward0. No forward pass information available. Enable detect anomaly during forward pass for more information. (Triggered internally at /Users/runner/work/pytorch/pytorch/pytorch/torch/csrc/autograd/python_anomaly_mode.cpp:97.)
  Variable._execution_engine.run_backward(  # Calls into the C++ engine to run the backward pass
Traceback (most recent call last):
  File "/Users/amedama/Documents/temporary/anodet.py", line 34, in <module>
    main()
  File "/Users/amedama/Documents/temporary/anodet.py", line 30, in main
    out.backward()
  File "/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/_tensor.py", line 488, in backward
    torch.autograd.backward(
  File "/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/autograd/__init__.py", line 197, in backward
    Variable._execution_engine.run_backward(  # Calls into the C++ engine to run the backward pass
RuntimeError: Function 'MseLossBackward0' returned nan values in its 0th output.

まとめ

今回は PyTorch でバックプロパゲーションが上手くいかない場所を自動で見つけることのできる機能を試してみた。

参考

pytorch.org

pytorch.org

Python: TabNet を使ってみる

一般に、テーブルデータの教師あり学習では、勾配ブースティング決定木の性能の良さについて語られることが多い。 これは、汎化性能の高さや前処理の容易さ、学習・推論の速さ、解釈可能性の高さなどが理由として挙げられる。 一方で、ニューラルネットワークをテーブルデータに適用する取り組みについても、以前から様々な試みがある。 今回は、その中でも近年機械学習コンテストにおいて結果を残している TabNet というモデルを試してみる。 TabNet には Unsupervised pre-training と Supervised fine-tuning を組み合わせた学習方法や、モデルの解釈可能性を向上させる試みなどに特徴がある。

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

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=20.04
DISTRIB_CODENAME=focal
DISTRIB_DESCRIPTION="Ubuntu 20.04.5 LTS"
$ uname -srm
Linux 5.15.0-56-generic x86_64
$ python -V
Python 3.10.9
$ pip list | grep -i torch
pytorch-tabnet               4.0
torch                        1.13.1

もくじ

下準備

あらかじめ利用するパッケージをインストールしておく。 pytorch-tabnet は非公式ながら PyTorch を使った TabNet の最も有名な実装となる。

$ pip install pytorch-tabnet seaborn scikit-learn

回帰タスク (Diamonds データセット)

今回は試しに Diamonds データセット 1 について、価格 (price) カラムを目的変数として RMSLE で評価してみよう。

早速だけど以下にサンプルコードを示す。 TabNet の特徴として Unsupervised pre-training で学習した重みを Supervised fine-tuning の学習に転用する点が挙げられる。 これらは、それぞれ TabNetPretrainerTabNetRegressor というクラスとして表現されている。 いずれも scikit-learn の Transformer API を実装しているため簡便に扱うことができる。 なお、Unsupervised pre-training は必須ではないことから、精度を落としても構わなければ省略できる。 ハイパーパラメータなどについては、デフォルトからいじっているのは Entity Embedding の埋め込み次元数と学習率、エポック数くらい。

import math

import numpy as np
import pandas as pd
import seaborn as sns
from pytorch_tabnet.pretraining import TabNetPretrainer
from pytorch_tabnet.tab_model import TabNetRegressor
from sklearn.compose import ColumnTransformer
from sklearn.metrics import mean_squared_error
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.preprocessing import OrdinalEncoder
import torch
from torch import nn
from matplotlib import pyplot as plt


def rmsle_metric(y_pred, y_true):
    """Root Mean Squared Logarithmic Error を計算する関数"""
    clamped_y_pred = np.clip(y_pred, a_min=0., a_max=None)
    log_y_pred = np.log1p(clamped_y_pred)
    log_y_true = np.log1p(y_true)
    return mean_squared_error(log_y_pred, log_y_true, squared=False)


class RMSLELoss(nn.Module):
    """PyTorch で RMSLE を計算するモジュール"""

    def __init__(self):
        super().__init__()
        self.mse_loss = nn.MSELoss()

    def forward(self, y_pred, y_true):
        # 入力が -1 以下になると NaN になるため値に下限を設ける
        clamped_y_pred = torch.clamp(y_pred, min=0.)
        log_y_pred = torch.log1p(clamped_y_pred)
        log_y_true = torch.log1p(y_true)
        msle = self.mse_loss(log_y_pred, log_y_true)
        rmsle_loss = torch.sqrt(msle)
        return rmsle_loss


def main():
    # Diamonds データセットを読み込む
    df = sns.load_dataset('diamonds')
    x, y = df.drop(['price'], axis=1), df['price'].values

    # カテゴリ変数は Ordinal Encode する
    categorical_cut_pipeline = Pipeline(steps=[
        ("cut-ordinal", OrdinalEncoder())
    ])
    categorical_color_pipeline = Pipeline(steps=[
        ("color-ordinal", OrdinalEncoder())
    ])
    categorical_clarity_pipeline = Pipeline(steps=[
        ("clarity-ordinal", OrdinalEncoder())
    ])
    # 連続変数は標準化する
    numerical_pipeline = Pipeline(steps=[
        ("numerical", StandardScaler()),
    ])
    pipeline = ColumnTransformer(transformers=[
        ("categorical-cut-pipeline", categorical_cut_pipeline, ["cut"]),
        ("categorical-color-pipeline", categorical_color_pipeline, ["color"]),
        ("categorical-clarity-pipeline", categorical_clarity_pipeline, ["clarity"]),
        ("numerical-pipeline", numerical_pipeline, ["carat", "depth", "table", "x", "y", "z"])
    ])
    transformed_x = pipeline.fit_transform(x)

    # 最終的な汎化性能の評価に使うホールドアウト
    train_x, test_x, train_y, test_y = train_test_split(
        transformed_x,
        y.reshape(-1, 1),
        test_size=0.33,
        random_state=42,
        shuffle=True,
    )

    # 学習に使うために学習データと評価データを分割する
    train_x, eval_x, train_y, eval_y = train_test_split(
        train_x,
        train_y,
        test_size=0.2,
        random_state=42,
        shuffle=True,
    )

    # カテゴリ変数のインデックスとユニークな値の数を計算しておく
    categorical_index_with_modalities = {
        i: np.unique(transformed_x[:, i]).shape[0]
        for i in [0, 1, 2]
    }

    # 学習パラメータ
    tabnet_params = {
        # カテゴリ変数は Entity Embedding する
        "cat_idxs": list(categorical_index_with_modalities.keys()),
        "cat_dims": list(categorical_index_with_modalities.values()),
        # 埋め込み次元数はユニークな値の数から目安を決める
        "cat_emb_dim": [math.ceil(n ** 0.25) for n in categorical_index_with_modalities.values()],
        "optimizer_params": {
            "lr": 2e-3,
        },
    }

    # Unsupervised pre-training
    unsupervised_model = TabNetPretrainer(**tabnet_params)
    unsupervised_model.fit(
        X_train=train_x,
        eval_set=[
            eval_x,
        ],
        max_epochs=1_000,
        # 精度の改善が見られないときに Early stopping をかけるエポック数
        patience=100,
    )

    # Supervised fine-tuning
    clf = TabNetRegressor(**tabnet_params)
    clf.fit(
        X_train=train_x,
        y_train=train_y,
        eval_set=[
            (eval_x, eval_y),
        ],
        loss_fn=RMSLELoss(),
        eval_metric=[
            "rmsle",
        ],
        max_epochs=1_000,
        patience=100,
        from_unsupervised=unsupervised_model,
    )

    test_y_pred = clf.predict(test_x)

    # ホールドアウトしたテストデータで汎化性能を求める
    test_loss = rmsle_metric(test_y_pred, test_y)
    print(f"Test Loss: {test_loss:.8f}")

    # 特徴量の重要度を可視化する
    fig, ax = plt.subplots(1, 1)
    feature_imprtance = pd.Series(data={k: v for k, v in zip(x.columns, clf.feature_importances_)})
    feature_imprtance.sort_values().plot(kind="barh", ax=ax)
    ax.set_xlabel("importance")
    ax.set_ylabel("features")
    fig.savefig("importance.png")


if __name__ == '__main__':
    main()

上記を実行してみよう。 GPU (CUDA) は認識されていれば自動で使われる。 マシンの計算リソースによっては、だいぶ時間がかかる。

$ python tabnet_diamonds.py

...(snip)...

epoch 811| loss: 0.11108 | val_0_rmsle: 0.01169 |  0:20:33s
epoch 812| loss: 0.11114 | val_0_rmsle: 0.01161 |  0:20:34s
epoch 813| loss: 0.11284 | val_0_rmsle: 0.01422 |  0:20:36s

Early stopping occurred at epoch 813 with best_epoch = 713 and best_val_0_rmsle = 0.01049
/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/pytorch_tabnet/callbacks.py:172: UserWarning: Best weights from best epoch are automatically used!
  warnings.warn(wrn_msg)
Test Loss: 0.10144833

テストデータに対して 0.10144833 という精度が得られた。

また、次のような特徴量の重要度を可視化したグラフが得られる。

TabNet の特徴量重要度

参考: LightGBM の場合のスコア

参考までに、勾配ブースティング決定木の代表として LightGBM を使った場合についても記載する。 あらかじめ LightGBM のパッケージをインストールする。

$ pip install lightgbm

サンプルコードは次のとおり。 LightGBM には学習用の損失関数として RMSLE が用意されていない。 そこで、代わりに目的変数を numpy.log1p() で変換した上で RMSE で学習している。

import numpy as np
import seaborn as sns
from sklearn.compose import ColumnTransformer
from sklearn.metrics import mean_squared_error
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.preprocessing import OrdinalEncoder
import lightgbm as lgb


def rmse_metric(y_pred, y_true):
    return mean_squared_error(y_pred, y_true, squared=False)


def main():
    df = sns.load_dataset('diamonds')
    x, y = df.drop(['price'], axis=1), df['price'].values

    y = np.log1p(y)

    categorical_cut_pipeline = Pipeline(steps=[
        ("cut-ordinal", OrdinalEncoder())
    ])
    categorical_color_pipeline = Pipeline(steps=[
        ("color-ordinal", OrdinalEncoder())
    ])
    categorical_clarity_pipeline = Pipeline(steps=[
        ("clarity-ordinal", OrdinalEncoder())
    ])
    numerical_pipeline = Pipeline(steps=[
        ("numerical", StandardScaler()),
    ])
    pipeline = ColumnTransformer(transformers=[
        ("categorical-cut-pipeline", categorical_cut_pipeline, ["cut"]),
        ("categorical-color-pipeline", categorical_color_pipeline, ["color"]),
        ("categorical-clarity-pipeline", categorical_clarity_pipeline, ["clarity"]),
        ("numerical-pipeline", numerical_pipeline, ["carat", "depth", "table", "x", "y", "z"])
    ])
    transformed_x = pipeline.fit_transform(x)

    train_x, test_x, train_y, test_y = train_test_split(
        transformed_x,
        y.reshape(-1, 1),
        test_size=0.33,
        random_state=42,
        shuffle=True,
    )

    train_x, eval_x, train_y, eval_y = train_test_split(
        train_x,
        train_y,
        test_size=0.2,
        random_state=42,
        shuffle=True,
    )

    lgb_train = lgb.Dataset(train_x, train_y)
    lgb_eval = lgb.Dataset(eval_x, eval_y, reference=lgb_train)

    lgb_params = {
        'objective': 'regression',
        'metric': "rmse",
        'verbose': -1,
        'seed': 42,
        'deterministic': True,
    }
    callbacks = [
        lgb.log_evaluation(period=10),
        lgb.early_stopping(
            stopping_rounds=100,
            first_metric_only=True,
        ),
    ]

    booster = lgb.train(
        params=lgb_params,
        train_set=lgb_train,
        valid_sets=[lgb_train, lgb_eval],
        num_boost_round=1_000,
        callbacks=callbacks,
    )

    test_y_pred = booster.predict(test_x,
                                  num_iteration=booster.best_iteration)

    test_loss = rmse_metric(test_y_pred, test_y)
    print(f"Test Loss: {test_loss:.8f}")


if __name__ == '__main__':
    main()

上記の実行結果は次のとおり。

$ python lgbm_diamonds.py

... (snip) ...

[700]  training's rmse: 0.0670463  valid_1's rmse: 0.0860752
[710]  training's rmse: 0.0668764  valid_1's rmse: 0.0860719
[720]  training's rmse: 0.0667363  valid_1's rmse: 0.0860665
Early stopping, best iteration is:
[623]  training's rmse: 0.0684748  valid_1's rmse: 0.0860188
Evaluated only: rmse
Test Loss: 0.08698780

テストデータに対して 0.08698780 という精度が得られた。

今回の条件においては LightGBM に分があるようだが、これはもちろんデータの性質などにも依存する。 また、性能を向上させる試みとして、勾配ブースティング決定木とニューラルネットワークをアンサンブルするのが常套手段である点も見逃すことはできない。

参考

github.com

arxiv.org


  1. データの量がさほど多くないため、ニューラルネットワークが得意とするデータの性質ではないかもしれない

Linux でリンクアグリゲーション (LAG) を試してみる

リンクアグリゲーションは、複数のネットワークインターフェイスを束ねて扱う技術の総称。 たとえば、2 本のイーサネットを束ねて冗長化することで 1 本に障害が起こってもサービスを提供し続けることができる。 あるいは、フレームをロードバランスすることで単一のイーサネットよりもスループットを向上させる用途でも用いられる。 今回は Linux のリンクアグリゲーションの実装であるボンディング (Bonding) を Network Namespace と共に使ってみよう。

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

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=22.04
DISTRIB_CODENAME=jammy
DISTRIB_DESCRIPTION="Ubuntu 22.04.1 LTS"
$ uname -srm
Linux 5.15.0-56-generic aarch64
$ ip -V
ip utility, iproute2-5.15.0, libbpf 0.5.0

もくじ

下準備

まずは利用するパッケージをインストールしておく。

$ sudo apt-get update
$ sudo apt-get install iproute2 tcpdump iputils-ping

ネットワークを構築する

ここからは Network Namespace と Virtual Ethernet Device を使ってネットワークを構築していく。 今回は ns1ns2bridge という名前で Network Namespace を用意してつないでいく。 物理構成としては [ns1]=[bridge]-[ns2] という感じ。 ns1bridge の間が 2 本のネットワークインターフェイスでつながる。

$ sudo ip netns add ns1
$ sudo ip netns add ns2
$ sudo ip netns add bridge

続いて Virtual Ethernet Device を追加する。 ここで ns1 に所属させる Virtual Ethernet Device は ns1-veth0ns1-veth1 という 2 つを用意する。 これらをボンディングで冗長化する。

$ sudo ip link add ns1-veth0 type veth peer name ns1-veth0-br0
$ sudo ip link add ns1-veth1 type veth peer name ns1-veth1-br0
$ sudo ip link add ns2-veth0 type veth peer name ns2-br0

それぞれのネットワークインターフェイスを Network Namespace に所属させていく。

$ sudo ip link set ns1-veth0 netns ns1
$ sudo ip link set ns1-veth1 netns ns1
$ sudo ip link set ns2-veth0 netns ns2
$ sudo ip link set ns1-veth0-br0 netns bridge
$ sudo ip link set ns1-veth1-br0 netns bridge
$ sudo ip link set ns2-br0 netns bridge

ns1 側のネットワークインターフェイスの設定をする。 まずは ns1-bond0 という名前でボンディング用のインターフェイスを用意する。 miimon というのは MII (Media Independent Interface) で何 ms ごとにインターフェイスの状態を監視するかを表している。 その後ろの mode active-backup はボンディングのやり方で、active-backup だと 1 つのインターフェイスだけがアクティブになって通信に使われる。

$ sudo ip netns exec ns1 ip link add ns1-bond0 type bond miimon 100 mode active-backup

ボンディング用のインターフェイスに 2 つの Virtual Network Device を所属させる。

$ sudo ip netns exec ns1 ip link set ns1-veth0 master ns1-bond0
$ sudo ip netns exec ns1 ip link set ns1-veth1 master ns1-bond0
$ sudo ip netns exec ns1 ip link set ns1-veth0 up
$ sudo ip netns exec ns1 ip link set ns1-veth1 up

ボンディング用のインターフェイスの状態を UP に設定して、IP アドレスを付与する。 これで 2 つの Virtual Ethernet Device を冗長化した形でボンディング用のインターフェイスが使えるようになった。

$ sudo ip netns exec ns1 ip link set ns1-bond0 up
$ sudo ip netns exec ns1 ip address add 192.0.2.1/24 dev ns1-bond0

次は ns2 の方のインターフェイスを設定する。 こちらは特に説明すべきことはない。

$ sudo ip netns exec ns2 ip link set ns2-veth0 up
$ sudo ip netns exec ns2 ip address add 192.0.2.2/24 dev ns2-veth0

次に bridge のインターフェイスを設定する。 Linux ブリッジを用意して、それぞれのネットワークインターフェイスをつないで使える状態にする。

$ sudo ip netns exec bridge ip link set ns1-veth0-br0 up
$ sudo ip netns exec bridge ip link set ns1-veth1-br0 up
$ sudo ip netns exec bridge ip link set ns2-br0 up
$ sudo ip netns exec bridge ip link add dev br0 type bridge
$ sudo ip netns exec bridge ip link set br0 up
$ sudo ip netns exec bridge ip link set ns1-veth0-br0 master br0
$ sudo ip netns exec bridge ip link set ns1-veth1-br0 master br0
$ sudo ip netns exec bridge ip link set ns2-br0 master br0

動作を確認する

さて、これですべての準備が整った。 ns1 から ns2 の IP アドレスに向かって ping を打ってみよう。 うまくいけば、ちゃんと応答が返ってくるはず。

$ sudo ip netns exec ns1 ping 192.0.2.2 -I 192.0.2.1
PING 192.0.2.2 (192.0.2.2) from 192.0.2.1 : 56(84) bytes of data.
64 bytes from 192.0.2.2: icmp_seq=1 ttl=64 time=0.110 ms
64 bytes from 192.0.2.2: icmp_seq=2 ttl=64 time=0.157 ms
64 bytes from 192.0.2.2: icmp_seq=3 ttl=64 time=0.175 ms

通信がどのように流れているかを観察してみよう。 まずは ns1 のボンディング用インターフェイス ns1-bond0 から。 ちゃんと ICMP echo request / reply がやり取りされている。

$ sudo ip netns exec ns1 tcpdump -tnl -i ns1-bond0 icmp
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on ns1-bond0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
IP 192.0.2.1 > 192.0.2.2: ICMP echo request, id 56648, seq 13, length 64
IP 192.0.2.2 > 192.0.2.1: ICMP echo reply, id 56648, seq 13, length 64
IP 192.0.2.1 > 192.0.2.2: ICMP echo request, id 56648, seq 14, length 64
IP 192.0.2.2 > 192.0.2.1: ICMP echo reply, id 56648, seq 14, length 64
IP 192.0.2.1 > 192.0.2.2: ICMP echo request, id 56648, seq 15, length 64
IP 192.0.2.2 > 192.0.2.1: ICMP echo reply, id 56648, seq 15, length 64

次は ns1ns1-veth0 を観察してみよう。 このインターフェイスは ns1-bond0 によって束ねられている。 こちらも ICMP echo request / reply がやり取りされているようだ。

$ sudo ip netns exec ns1 tcpdump -tnl -i ns1-veth0 icmp
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on ns1-veth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
IP 192.0.2.1 > 192.0.2.2: ICMP echo request, id 56648, seq 35, length 64
IP 192.0.2.2 > 192.0.2.1: ICMP echo reply, id 56648, seq 35, length 64
IP 192.0.2.1 > 192.0.2.2: ICMP echo request, id 56648, seq 36, length 64
IP 192.0.2.2 > 192.0.2.1: ICMP echo reply, id 56648, seq 36, length 64
IP 192.0.2.1 > 192.0.2.2: ICMP echo request, id 56648, seq 37, length 64
IP 192.0.2.2 > 192.0.2.1: ICMP echo reply, id 56648, seq 37, length 64

では ns1ns1-veth1 はどうだろうか。 このインターフェースも ns1-bond0 によって束ねられている。 しかし、このインターフェイスにはパケットが流れてこない。 これはボンディングのモードが active-backup になっているため。 つまり、こちらのインターフェイスがバックアップになっている。

$ sudo ip netns exec ns1 tcpdump -tnl -i ns1-veth1 icmp
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on ns1-veth1, link-type EN10MB (Ethernet), snapshot length 262144 bytes

試しにアクティブになっている方のインターフェイスをダウンさせてみよう。 bridge の方に所属している ns1-veth0-br0 の状態を DOWN に設定する。

$ sudo ip netns exec bridge ip link set ns1-veth0-br0 down

すると、バックアップになっていた ns1-veth1 の方にパケットが流れ始める。 障害を検知してこちらがマスターに昇格したようだ。

$ sudo ip netns exec ns1 tcpdump -tnl -i ns1-veth1 icmp
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on ns1-veth1, link-type EN10MB (Ethernet), snapshot length 262144 bytes
IP 192.0.2.1 > 192.0.2.2: ICMP echo request, id 56648, seq 82, length 64
IP 192.0.2.2 > 192.0.2.1: ICMP echo reply, id 56648, seq 82, length 64
IP 192.0.2.1 > 192.0.2.2: ICMP echo request, id 56648, seq 83, length 64
IP 192.0.2.2 > 192.0.2.1: ICMP echo reply, id 56648, seq 83, length 64
IP 192.0.2.1 > 192.0.2.2: ICMP echo request, id 56648, seq 84, length 64
IP 192.0.2.2 > 192.0.2.1: ICMP echo reply, id 56648, seq 84, length 64

試しにダウンしていたインターフェイスの状態を UP に戻してみよう。

$ sudo ip netns exec bridge ip link set ns1-veth0-br0 up

それでも、ns1-veth1 の方にパケットが流れ続ける。 元のマスターとバックアップの状態に自動で戻らないのは、フラッピングを防ぐ上で合理的といえる。

$ sudo ip netns exec ns1 tcpdump -tnl -i ns1-veth1 icmp
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on ns1-veth1, link-type EN10MB (Ethernet), snapshot length 262144 bytes

...

IP 192.0.2.1 > 192.0.2.2: ICMP echo request, id 56648, seq 108, length 64
IP 192.0.2.2 > 192.0.2.1: ICMP echo reply, id 56648, seq 108, length 64
IP 192.0.2.1 > 192.0.2.2: ICMP echo request, id 56648, seq 109, length 64
IP 192.0.2.2 > 192.0.2.1: ICMP echo reply, id 56648, seq 109, length 64
IP 192.0.2.1 > 192.0.2.2: ICMP echo request, id 56648, seq 110, length 64
IP 192.0.2.2 > 192.0.2.1: ICMP echo reply, id 56648, seq 110, length 64

いじょう。

まとめ

今回は Linux におけるリンクアグリゲーション (LAG) の実装であるボンディングを試してみた。 ボンディングを使うことで、ネットワークインターフェイスを束ねて扱うことができる。

Linux TC (Traffic Control) の netem (Network Emulator) で劣悪なネットワーク環境を再現する

今回は Linux TC (Traffic Control) に実装されている netem (Network Emulator) という qdisc (Queueing Discipline) を使って、劣悪なネットワーク環境を再現する方法について書いてみる。 Linux TC は、文字通り Linux のトラフィックを制御するための機能で、qdisc という形で様々な制御のやり方が実装されている。 その中でも netem という qdisc には、意図的にフレームの送出にディレイを入れたりドロップするといった機能が備わっている。 これを使うと、実際に環境を用意してテストするのがなかなか難しいようなネットワークの状況を再現できる。

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

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=22.04
DISTRIB_CODENAME=jammy
DISTRIB_DESCRIPTION="Ubuntu 22.04.1 LTS"
$ uname -srm
Linux 5.15.0-53-generic aarch64
$ tc -V
tc utility, iproute2-5.15.0, libbpf 0.5.0
$ iperf3 -v
iperf 3.9 (cJSON 1.7.13)
Linux jammy 5.15.0-53-generic #59-Ubuntu SMP Mon Oct 17 18:55:29 UTC 2022 aarch64
Optional features available: CPU affinity setting, IPv6 flow label, SCTP, TCP congestion algorithm setting, sendfile / zerocopy, socket pacing, authentication

もくじ

下準備

まずは、使用するパッケージをインストールする。

$ sudo apt-get -y install iproute2 iputils-ping iperf3

今回は Network Namespace で作った仮想的なネットワーク環境で netem の機能を試す。 あらかじめ、ns1ns2 という Network Namespace を作っておく。

$ sudo ip netns add ns1
$ sudo ip netns add ns2

次に Virtual Ethernet インターフェイスで veth0veth1 を作って Network Namespace 同士をつなぐ。

$ sudo ip link add veth0 type veth peer name veth1
$ sudo ip link set veth0 netns ns1
$ sudo ip link set veth1 netns ns2
$ sudo ip netns exec ns1 ip link set veth0 up
$ sudo ip netns exec ns2 ip link set veth1 up

それぞれのインターフェイスに IP アドレスとして 192.0.2.1/24192.0.2.2/24 を付与する。

$ sudo ip netns exec ns1 ip address add 192.0.2.1/24 dev veth0
$ sudo ip netns exec ns2 ip address add 192.0.2.2/24 dev veth1

これで、ひとまず IP で疎通がある状態になった。 RTT に遅延はほとんどなく 100us を下回っている。

$ sudo ip netns exec ns1 ping -c 3 192.0.2.2 -I 192.0.2.1
PING 192.0.2.2 (192.0.2.2) from 192.0.2.1 : 56(84) bytes of data.
64 bytes from 192.0.2.2: icmp_seq=1 ttl=64 time=0.097 ms
64 bytes from 192.0.2.2: icmp_seq=2 ttl=64 time=0.085 ms
64 bytes from 192.0.2.2: icmp_seq=3 ttl=64 time=0.081 ms

--- 192.0.2.2 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2137ms
rtt min/avg/max/mdev = 0.081/0.087/0.097/0.006 ms

何もしない状態で iperf3 を使って帯域幅を確認しておく。 まずは、新しくターミナルを開いて ns2 の方でサーバを立ち上げる。

$ sudo ip netns exec ns2 iperf3 -s
-----------------------------------------------------------
Server listening on 5201
-----------------------------------------------------------

そして ns1 の方でクライアントを立ち上げる。 次のとおり、70Gbps ほど出ている。

$ sudo ip netns exec ns1 iperf3 -c 192.0.2.2
Connecting to host 192.0.2.2, port 5201
[  5] local 192.0.2.1 port 60428 connected to 192.0.2.2 port 5201
[ ID] Interval           Transfer     Bitrate         Retr  Cwnd
[  5]   0.00-1.00   sec  8.26 GBytes  70.9 Gbits/sec    0   3.07 MBytes       
[  5]   1.00-2.00   sec  8.68 GBytes  74.6 Gbits/sec    0   3.07 MBytes       
[  5]   2.00-3.00   sec  8.63 GBytes  74.1 Gbits/sec    0   3.07 MBytes       
[  5]   3.00-4.00   sec  8.72 GBytes  74.9 Gbits/sec    0   3.07 MBytes       
[  5]   4.00-5.00   sec  8.71 GBytes  74.8 Gbits/sec    0   3.07 MBytes       
[  5]   5.00-6.00   sec  8.60 GBytes  73.9 Gbits/sec    0   3.07 MBytes       
[  5]   6.00-7.00   sec  8.70 GBytes  74.8 Gbits/sec    0   3.07 MBytes       
[  5]   7.00-8.00   sec  8.62 GBytes  74.1 Gbits/sec    0   3.07 MBytes       
[  5]   8.00-9.00   sec  8.72 GBytes  74.9 Gbits/sec    0   3.07 MBytes       
[  5]   9.00-10.00  sec  8.73 GBytes  75.0 Gbits/sec    0   3.07 MBytes       
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval           Transfer     Bitrate         Retr
[  5]   0.00-10.00  sec  86.4 GBytes  74.2 Gbits/sec    0             sender
[  5]   0.00-10.04  sec  86.4 GBytes  73.9 Gbits/sec                  receiver

iperf Done.

サーバは、ひとまず立ち上げたままにしておこう。

レイテンシを大きくする

まずは、モバイルネットワークだったり通信先が地球の反対側にあるような、レイテンシの大きな状況を再現してみる。

Linux TC は tc(8) というコマンドを使って操作する。 そして、Linux TC の qdisc はネットワークインターフェイスに設定される。 試しに tc qdisc show サブコマンドを使って ns2veth1 インターフェイスに設定されている qdisc を確認してみよう。

$ sudo ip netns exec ns2 \
    tc qdisc show dev veth1
qdisc noqueue 0: root refcnt 2

当然だけど、初期状態ではインターフェイスに何も qdisc が設定されていない。

ここで、tc qdisc add サブコマンドを使って ns2veth1 に netem qdisc を設定してみよう。 qdisc の名前である netem の後ろには、その qdisc 固有のパラメータを指定する。 delay 200ms 100ms というのは、100ms のジッターのある 200ms のディレイを挿入する、という意味になる。

$ sudo ip netns exec ns2 \
    tc qdisc add dev veth1 root \
    netem delay 200ms 100ms

なお、netem の前に入っている root は「そのインターフェイスのルートの qdisc を設定する」という意味になる。 というのも、1 つのインターフェイスには複数の qdisc を親子関係のあるツリー構造で設定できるため。 そのため、操作する位置を指定する必要がある。 ただし、今回は簡単のために qdisc の位置は root しか扱わない。

さて、再度 tc qdisc show サブコマンドで確認すると、今度は qdisc として netem が設定されていることがわかる。

$ sudo ip netns exec ns2 \
    tc qdisc show dev veth1
qdisc netem 8002: root refcnt 2 limit 1000 delay 200ms  100ms

この状態で、最初に確認したのと同じように ping(8) を使ってみよう。 すると、今度は RTT が 200 ~ 300ms 前後にまで増加している。

$ sudo ip netns exec ns1 ping -c 10 192.0.2.2 -I 192.0.2.1
PING 192.0.2.2 (192.0.2.2) from 192.0.2.1 : 56(84) bytes of data.
64 bytes from 192.0.2.2: icmp_seq=1 ttl=64 time=300 ms
64 bytes from 192.0.2.2: icmp_seq=2 ttl=64 time=326 ms
64 bytes from 192.0.2.2: icmp_seq=3 ttl=64 time=287 ms
64 bytes from 192.0.2.2: icmp_seq=4 ttl=64 time=292 ms
64 bytes from 192.0.2.2: icmp_seq=5 ttl=64 time=202 ms
64 bytes from 192.0.2.2: icmp_seq=6 ttl=64 time=303 ms
64 bytes from 192.0.2.2: icmp_seq=7 ttl=64 time=262 ms
64 bytes from 192.0.2.2: icmp_seq=8 ttl=64 time=292 ms
64 bytes from 192.0.2.2: icmp_seq=9 ttl=64 time=216 ms
64 bytes from 192.0.2.2: icmp_seq=10 ttl=64 time=298 ms

--- 192.0.2.2 ping statistics ---
10 packets transmitted, 10 received, 0% packet loss, time 9389ms
rtt min/avg/max/mdev = 202.402/277.962/326.268/37.638 ms

どうやら、ちゃんとディレイが入ったようだ。 これこそが netem の効果というわけ。

実験し終わったら、設定していた qdisc をインターフェイスから削除しよう。

$ sudo ip netns exec ns2 \
    tc qdisc delete dev veth1 root

ここからは、同じように次の流れで各機能を説明していく。

  1. qdisc を設定する
  2. 動作を確認する
  3. qdisc を削除する

また、それぞれの設定を基本的には単独で試していくけど、実際には複数を組み合わせることもできる。

パケットロスを生じさせる

続いてはいわゆるパケロス 1 を起こしてみよう。 先ほどと同じように ns2veth1 に qdisc を設定する。 この点は、後述するすべての実験において変わらない。 変わるのは netem の設定が loss random 30% となっている点だ。

$ sudo ip netns exec ns2 \
    tc qdisc add dev veth1 root \
    netem loss random 30%

確認すると、ちゃんと qdisc が設定されている。 これで 30% の確率でパケットロスが生じるはず。

$ sudo ip netns exec ns2 \
    tc qdisc show dev veth1
qdisc netem 8002: root refcnt 5 limit 1000 loss 30%

実際に ping を打って試してみよう。

$ sudo ip netns exec ns1 \
    ping -c 10 192.0.2.2 -I 192.0.2.1
PING 192.0.2.2 (192.0.2.2) from 192.0.2.1 : 56(84) bytes of data.
64 bytes from 192.0.2.2: icmp_seq=2 ttl=64 time=0.138 ms
64 bytes from 192.0.2.2: icmp_seq=3 ttl=64 time=0.101 ms
64 bytes from 192.0.2.2: icmp_seq=4 ttl=64 time=0.162 ms
64 bytes from 192.0.2.2: icmp_seq=5 ttl=64 time=0.150 ms
64 bytes from 192.0.2.2: icmp_seq=6 ttl=64 time=0.114 ms
64 bytes from 192.0.2.2: icmp_seq=7 ttl=64 time=0.093 ms
64 bytes from 192.0.2.2: icmp_seq=8 ttl=64 time=0.110 ms

--- 192.0.2.2 ping statistics ---
10 packets transmitted, 7 received, 30% packet loss, time 9255ms
rtt min/avg/max/mdev = 0.093/0.124/0.162/0.024 ms

実行する毎に結果は変わるものの、上記ではちゃんと 30% のパケットが失われた。 なお、設定しているのが ns2 のインターフェイスなので、失われているのは戻りの ICMP Echo Reply になる。

実験し終わったら qdisc を削除する。

$ sudo ip netns exec ns2 \
    tc qdisc delete dev veth1 root

データを破損させる

続いてはフレームのデータを破損させる。 これはようするにビットを化けさせるということ。 引き起こされる事象は、破損が生じた箇所によって異なる。 以下では corrupt 50% と設定することで 50% の確率でフレームのいずれかのビットが化けることになる。

$ sudo ip netns exec ns2 \
    tc qdisc add dev veth1 root \
    netem corrupt 50%
$ sudo ip netns exec ns2 \
    tc qdisc show dev veth1
qdisc netem 8005: root refcnt 2 limit 1000 corrupt 50%

この状況で ping を打ってみよう。

$ sudo ip netns exec ns1     ping -c 10 192.0.2.2 -I 192.0.2.1
PING 192.0.2.2 (192.0.2.2) from 192.0.2.1 : 56(84) bytes of data.
ping: Warning: time of day goes back (-9007199254740929us), taking countermeasures
ping: Warning: time of day goes back (-9007199254740648us), taking countermeasures
64 bytes from 192.0.2.2: icmp_seq=1 ttl=64 time=0.000 ms
64 bytes from 192.0.2.2: icmp_seq=2 ttl=64 time=0.184 ms
64 bytes from 192.0.2.2: icmp_seq=3 ttl=64 time=0.178 ms
wrong data byte #29 should be 0x1d but was 0x19
#16    10 11 12 13 14 15 16 17 18 19 1a 1b 1c 19 1e 1f 20 21 22 23 24 25 26 27 28 29 2a 2b 2c 2d 2e 2f 
#48    30 31 32 33 34 35 36 37 
64 bytes from 192.0.2.2: icmp_seq=4 ttl=64 time=0.201 ms
64 bytes from 192.0.2.2: icmp_seq=5 ttl=64 time=0.190 ms
64 bytes from 192.0.2.2: icmp_seq=6 ttl=64 time=0.112 ms
wrong data byte #36 should be 0x24 but was 0x4
#16    10 11 12 13 14 15 16 17 18 19 1a 1b 1c 1d 1e 1f 20 21 22 23 4 25 26 27 28 29 2a 2b 2c 2d 2e 2f 
#48    30 31 32 33 34 35 36 37 
64 bytes from 192.0.2.2: icmp_seq=7 ttl=64 time=0.209 ms
64 bytes from 192.0.2.2: icmp_seq=8 ttl=64 time=0.209 ms
wrong data byte #35 should be 0x23 but was 0x21
#16    10 11 12 13 14 15 16 17 18 19 1a 1b 1c 1d 1e 1f 20 21 22 21 24 25 26 27 28 29 2a 2b 2c 2d 2e 2f 
#48    30 31 32 33 34 35 36 37 
64 bytes from 192.0.2.2: icmp_seq=9 ttl=64 time=0.214 ms
wrong data byte #53 should be 0x35 but was 0x75
#16    10 11 12 13 14 15 16 17 18 19 1a 1b 1c 1d 1e 1f 20 21 22 23 24 25 26 27 28 29 2a 2b 2c 2d 2e 2f 
#48    30 31 32 33 34 75 36 37 
64 bytes from 192.0.2.2: icmp_seq=10 ttl=64 time=0.220 ms
wrong data byte #45 should be 0x2d but was 0x29
#16    10 11 12 13 14 15 16 17 18 19 1a 1b 1c 1d 1e 1f 20 21 22 23 24 25 26 27 28 29 2a 2b 2c 29 2e 2f 
#48    30 31 32 33 34 35 36 37 

--- 192.0.2.2 ping statistics ---
10 packets transmitted, 10 received, 0% packet loss, time 9401ms
rtt min/avg/max/mdev = 0.000/0.171/0.220/0.064 ms

上記では、どうやらフレームやパケットのヘッダ部分が破損することはなかったようだ。 というのも、ヘッダが破損すればチェックサムの不一致によって破棄されるはずなので。 その代わり、ICMP のペイロードが破損したことで wrong data というエラーが記録されている。 たとえば icmp_seq=3 においては、0x1d であるはずの箇所が 0x19 だったらしい。

実験が終わったら qdisc を削除する。

$ sudo ip netns exec ns2 \
    tc qdisc delete dev veth1 root

パケットを重複させる

続いてはパケットを重複させてみる。 これには duplicate という設定を使う。 以下では 50% の確率でパケット (フレーム) が重複するようになっている。

$ sudo ip netns exec ns2 \
    tc qdisc add dev veth1 root \
    netem duplicate 50%
$ sudo ip netns exec ns2 \
    tc qdisc show dev veth1
qdisc netem 800b: root refcnt 5 limit 1000 duplicate 50%

ping を打ってみよう。

$ sudo ip netns exec ns1     ping -c 10 192.0.2.2 -I 192.0.2.1
PING 192.0.2.2 (192.0.2.2) from 192.0.2.1 : 56(84) bytes of data.
64 bytes from 192.0.2.2: icmp_seq=1 ttl=64 time=0.163 ms
64 bytes from 192.0.2.2: icmp_seq=1 ttl=64 time=0.164 ms (DUP!)
64 bytes from 192.0.2.2: icmp_seq=2 ttl=64 time=0.150 ms
64 bytes from 192.0.2.2: icmp_seq=3 ttl=64 time=0.171 ms
64 bytes from 192.0.2.2: icmp_seq=3 ttl=64 time=0.172 ms (DUP!)
64 bytes from 192.0.2.2: icmp_seq=4 ttl=64 time=0.156 ms
64 bytes from 192.0.2.2: icmp_seq=4 ttl=64 time=0.158 ms (DUP!)
64 bytes from 192.0.2.2: icmp_seq=5 ttl=64 time=0.160 ms
64 bytes from 192.0.2.2: icmp_seq=5 ttl=64 time=0.161 ms (DUP!)
64 bytes from 192.0.2.2: icmp_seq=6 ttl=64 time=0.086 ms
64 bytes from 192.0.2.2: icmp_seq=7 ttl=64 time=0.159 ms
64 bytes from 192.0.2.2: icmp_seq=8 ttl=64 time=0.109 ms
64 bytes from 192.0.2.2: icmp_seq=9 ttl=64 time=0.141 ms
64 bytes from 192.0.2.2: icmp_seq=9 ttl=64 time=0.142 ms (DUP!)
64 bytes from 192.0.2.2: icmp_seq=10 ttl=64 time=0.091 ms

--- 192.0.2.2 ping statistics ---
10 packets transmitted, 10 received, +5 duplicates, 0% packet loss, time 9359ms
rtt min/avg/max/mdev = 0.086/0.145/0.172/0.026 ms

上記で (DUP!) という表示が、重複したパケットの到着を表している。

実験が終わったら qdisc を削除する。

$ sudo ip netns exec ns2 \
    tc qdisc delete dev veth1 root

パケットの順序を入れ替える

続いてはパケットの順序を入れ替えてみる。 いわゆるリオーダと呼ばれる事象だ。 以下では reorder 25% の設定で 25% の確率でパケットの順序を入れ替えている。 ただし、reorder を設定するときは delay の設定も必ず必要になるため 2s のディレイを入れている。

$ sudo ip netns exec ns2 \
    tc qdisc add dev veth1 root \
    netem delay 2s reorder 25%
$ sudo ip netns exec ns2 \
    tc qdisc show dev veth1
qdisc netem 8007: root refcnt 2 limit 1000 delay 2s reorder 25% gap 1

ディレイも組み合わせる必要があるのは、リオーダを実装しているメカニズムに起因している。 基本的な考え方は次のとおり。

  1. あるフレームが netem qdisc にキューイングされる
  2. ディレイによって (1) のフレームの送出 (デキュー) が遅延される
  3. 別のフレームが netem qdisc にキューイングされる
  4. 一定の確率で (3) のフレームが netem qdisc から即座に送出される
  5. ディレイが完了した上で (1) のフレームが送出される

実際に ping を使って試してみよう。

$ sudo ip netns exec ns1 \
    ping -c 10 192.0.2.2 -I 192.0.2.1
PING 192.0.2.2 (192.0.2.2) from 192.0.2.1 : 56(84) bytes of data.
64 bytes from 192.0.2.2: icmp_seq=2 ttl=64 time=0.128 ms
64 bytes from 192.0.2.2: icmp_seq=1 ttl=64 time=2041 ms
64 bytes from 192.0.2.2: icmp_seq=3 ttl=64 time=2024 ms
64 bytes from 192.0.2.2: icmp_seq=4 ttl=64 time=2002 ms
64 bytes from 192.0.2.2: icmp_seq=6 ttl=64 time=0.073 ms
64 bytes from 192.0.2.2: icmp_seq=5 ttl=64 time=2010 ms
64 bytes from 192.0.2.2: icmp_seq=7 ttl=64 time=2047 ms
64 bytes from 192.0.2.2: icmp_seq=8 ttl=64 time=2046 ms
64 bytes from 192.0.2.2: icmp_seq=9 ttl=64 time=2041 ms
64 bytes from 192.0.2.2: icmp_seq=10 ttl=64 time=2027 ms

--- 192.0.2.2 ping statistics ---
10 packets transmitted, 10 received, 0% packet loss, time 9344ms
rtt min/avg/max/mdev = 0.073/1623.841/2046.608/811.998 ms, pipe 2

上記において icmp_seq=2icmp_seq=6 は順序が入れ替わっている。 また、TTL が順序の入れ替わっていないものに比べると極端に短いことも確認できる。 これは、前述した動作原理によって引き起こされている。

実験が終わったら qdisc の設定を削除する。

$ sudo ip netns exec ns2 \
    tc qdisc delete dev veth1 root

帯域幅を狭くする

続いては帯域幅を狭くしてみる。 これには rate を設定すれば良い。 以下では帯域幅を 1Mbps に制限している。 limit はキューの大きさで、これが小さいと流量が大きいときに恐らくキュー溢れが生じて安定しないため念の為大きくしている。

$ sudo ip netns exec ns2 \
    tc qdisc add dev veth1 root \
    netem rate 1mbit limit 100k
$ sudo ip netns exec ns2 \
    tc qdisc show dev veth1
qdisc netem 8001: root refcnt 2 limit 102400 rate 1Mbit

iperf3 を使って帯域幅を確認しよう。 サーバをもし止めてしまっていたときはもう一度立ち上げ直す。 実行時のポイントとしては -R オプションを使って、サーバからクライアントへダウンロードする方向でトラフィックを流しているところ。 というのも netem はフレームの送出 (egress) に作用するため。 クライアントからサーバへのアップロードの方向にトラフィックを流すと、帯域幅が制限される ns2 の egress は TCP の ACK がチョロチョロと流れるだけなので意味がない。

$ sudo ip netns exec ns1 iperf3 -R -c 192.0.2.2
Connecting to host 192.0.2.2, port 5201
Reverse mode, remote host 192.0.2.2 is sending
[  5] local 192.0.2.1 port 33828 connected to 192.0.2.2 port 5201
[ ID] Interval           Transfer     Bitrate
[  5]   0.00-1.00   sec   130 KBytes  1.06 Mbits/sec                  
[  5]   1.00-2.00   sec   119 KBytes   971 Kbits/sec                  
[  5]   2.00-3.00   sec   117 KBytes   961 Kbits/sec                  
[  5]   3.00-4.01   sec   116 KBytes   940 Kbits/sec                  
[  5]   4.01-5.02   sec   119 KBytes   967 Kbits/sec                  
[  5]   5.02-6.01   sec   116 KBytes   960 Kbits/sec                  
[  5]   6.01-7.00   sec   115 KBytes   945 Kbits/sec                  
[  5]   7.00-8.00   sec   116 KBytes   950 Kbits/sec                  
[  5]   8.00-9.02   sec   119 KBytes   962 Kbits/sec                  
[  5]   9.02-10.05  sec   122 KBytes   965 Kbits/sec                  
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval           Transfer     Bitrate         Retr
[  5]   0.00-10.18  sec  2.50 MBytes  2.06 Mbits/sec    0             sender
[  5]   0.00-10.05  sec  1.16 MBytes   968 Kbits/sec                  receiver

iperf Done.

上記から、ちゃんと1Mbps に帯域幅が制限されていることが確認できる。

終わったら qdisc の設定を削除しよう。

$ sudo ip netns exec ns2 \
    tc qdisc delete dev veth1 root

幅のあるディレイを加える

続いては、最初に実験したレイテンシを大きくするのと似ている。 ただし、今度はある程度の幅を持たせたランダム性のあるディレイを加える。 これには slot という設定を使えば良い。 以下では最小 100 最大 1000ms という幅のあるディレイを設定している。

$ sudo ip netns exec ns2 \
    tc qdisc add dev veth1 root \
    netem slot 100ms 1000ms
$ sudo ip netns exec ns2 \
    tc qdisc show dev veth1
qdisc netem 8008: root refcnt 2 limit 1000 slot 100ms 1s

ping で動作を確認してみよう。

$ sudo ip netns exec ns1 \
    ping -c 10 192.0.2.2 -I 192.0.2.1
PING 192.0.2.2 (192.0.2.2) from 192.0.2.1 : 56(84) bytes of data.
64 bytes from 192.0.2.2: icmp_seq=1 ttl=64 time=1160 ms
64 bytes from 192.0.2.2: icmp_seq=2 ttl=64 time=123 ms
64 bytes from 192.0.2.2: icmp_seq=3 ttl=64 time=260 ms
64 bytes from 192.0.2.2: icmp_seq=4 ttl=64 time=614 ms
64 bytes from 192.0.2.2: icmp_seq=5 ttl=64 time=695 ms
64 bytes from 192.0.2.2: icmp_seq=6 ttl=64 time=173 ms
64 bytes from 192.0.2.2: icmp_seq=7 ttl=64 time=674 ms
64 bytes from 192.0.2.2: icmp_seq=8 ttl=64 time=791 ms
64 bytes from 192.0.2.2: icmp_seq=9 ttl=64 time=546 ms
64 bytes from 192.0.2.2: icmp_seq=10 ttl=64 time=971 ms

--- 192.0.2.2 ping statistics ---
10 packets transmitted, 10 received, 0% packet loss, time 9130ms
rtt min/avg/max/mdev = 123.187/600.742/1159.726/320.913 ms, pipe 2

上記から 100 ~ 1000ms 前後のバラついたディレイが入っていることが確認できる。

終わったら qdisc の設定を削除する。

$ sudo ip netns exec ns2 \
    tc qdisc delete dev veth1 root

まとめ

今回は Linux TC の netem qdisc に実装されている機能を一通り試した。 この機能を使えば、実際に用意するのが難しいような、ネットワークでごく稀に生じる事象も再現できる。


  1. 厳密にはフレームのレベルで起こっている事象ではあるものの面倒なので以降を含めあえて曖昧に書いている部分がある