システムを一時的に停止させるカーネルモジュール

はじめに

システムを一時的に停止させるstop-machineというカーネルモジュールを紹介します。ソースはここにあります。使いかたはREADMEを見てもらえればわかります。

Ubuntu 18.04上でlinuxカーネル4.18.0-18-genericを起動させた状態で動作検証をしました。

現在の実装ではカーネルモジュールをロードしてから5秒だけシステムを停止させます。ソースの中のSTOP_MSECS定数を変更すればミリ秒単位で一時停止させる時間を変更可能です1。ここでいう停止とは次のようなものです。

  • あらゆるプロセスが動作できない
  • あらゆるデバイスが動けない。正確にいうとデバイスから割り込みが上がってきても停止が解除するまで待たせられる。
  • 上記に伴いストレージI/O、ネットワークI/Oが動かないし、ユーザからの操作も一切付けつけない

考えられる用途

このモジュールは、システムが応答しなくなったような状態でソフトウェアがどう振舞うかというテストを簡単にできるので便利です。

本当に止まっているかを確認してみましょう。まずはモジュールをロードするのとは別の端末を開いて、1秒ごとに現在時刻を表示するスクリプトを動かします。

$ for ((;;)) ; do sleep 1 ; date -R ; done
Sat, 04 May 2019 09:40:27 +0900
Sat, 04 May 2019 09:40:28 +0900
Sat, 04 May 2019 09:40:29 +0900
Sat, 04 May 2019 09:40:30 +0900
Sat, 04 May 2019 09:40:31 +0900
...

この状態でもとの端末に戻ってモジュールをロードすると、約5秒後にshellのプロンプトが出てきます。

$ sudo insmod stop-machine.ko
$                 # insmodコマンドを発行してから約5秒後にこのプロンプトが出てくる 

一秒ごとに現在時刻を出力していた端末では5秒間時間が飛んだように見えていることがわかります(以下実行例の☆1から☆2の間)。

...
Sat, 04 May 2019 09:44:38 +0900
Sat, 04 May 2019 09:44:39 +0900
Sat, 04 May 2019 09:44:40 +0900
Sat, 04 May 2019 09:44:41 +0900
Sat, 04 May 2019 09:44:42 +0900
Sat, 04 May 2019 09:44:43 +0900
Sat, 04 May 2019 09:44:44 +0900 ☆1
Sat, 04 May 2019 09:44:49 +0900 ☆2
Sat, 04 May 2019 09:44:50 +0900
Sat, 04 May 2019 09:44:51 +0900
...

最後に現在時刻を出していた端末を閉じた上で、モジュールを以下のようにアンロードしておきましょう。

$ sudo rmmod stop-machine
$ 

実装のはなし

このモジュールは、linuxカーネル内のstop_machine()という関数を使っています。この関数は第一引数によってシステム停止中に実行するハンドラを、第二引数によってこのハンドラ実行時に渡される引数を、第三引数によって「どのCPUを停止させるか」を決めます(NULLにすれば全CPU)。stop_machine()関数はカーネル内で他の処理に一切割り込まれたくない処理において使われます。このモジュールにおいてはハンドラの中で1秒だけCPUを使うような無限ループを全CPU上で回しています。

stop_machine関数について興味のあるかたはソース2をごらんください。この記事を書いた後にakachochinさんが大変よい記事を書いてくださったのでまずはこちらを読むといいと思います。


  1. モジュールパラメタを使えばロード時にこの値を変更できるようになります。興味のあるかたはやってみてください。

  2. カーネルソース内のinclude/linux/stop_machine.hやkernel/stop_machine.cなど

なんでもかんでも「バグ」ってひとくくりにしないで

はじめに

プログラマがソフトウェアを作るとユーザがつきます。ユーザがそのソフトウェアを使っていて何らかの問題が発生すると「このソフトはバグってる、直して!」と言われることがままあります。それに対して「いや、仕様だから」と突っぱねられることがあります。その後お互いの意見が「バグだ!」「いいや仕様だ!」と平行線になってお互いモヤモヤのまま終わるというのはよくある話です。

なぜこういうことが起きるかというと、原因の一つは「問題」イコール「バグ」という短絡的な考え方です。とくにソフトウェアを作ったり使ったりした経験が浅い人がこうなる傾向があると推測しています。このような食い違いは「要件」「仕様」と「実装」という言葉の意味を理解していればある程度解決できます。本書はこれらの用語について実例を挙げて簡単に紹介します。

注意点

  • 本記事では要件や仕様を定義することが前提となっていますが、とくにユーザと開発者が同じような場合にはこれらが明確でないことがままあります。OSSにおけるこのあたりの感覚についてはこの記事が非常に参考になります。興味のあるかたはごらんください
  • 原因がわかったときにその責任がどこにあるかについて。たとえば原因がソフトウェアの誤りであることがわかっても、たとえばGPLのように「無保証」と銘打っているライセンスを採用しているソフトウェアについては「ここバグってるからソフトウェアを直して」とユーザが言っても開発者には直す責任はありません(運が良ければ直してくれることもあります)。この場合は自分で修正を作って開発者に提供して直してもらうなどの対処が考えられます。このあたりは本記事のスコープから外れますのでこれ以上は述べません。今後「開発者に責任がある」と書いている場合は、無保証のソフトウェアについては状況が違うと考えてください。
  • 本記事に書いてあるようなことは百も承知で、自分あるいは自分の所属する組織に有利になるようにゴネる人もいますが、そういうケースはここでは扱いません。同じく、責任が誰にあるかは明確であってもユーザと開発者の力関係によって誰に責任があるのかが政治的に決まる、場合によっては両者の意見の相違によって裁判沙汰になることもありますが、それについてもここでは扱いません。

要件

ソフトウェアを作る際には、最初に「いったどういうソフトウェアを作りたいのか」という要件を定義します。ここでは次のような要件があるとします。

  • ある組織に複数の人が属している
  • 個々人について名前と年齢という情報が並べられた表を管理したい
  • 年齢をキーとして組織に属する人すべての情報を昇順に表示したい

この要件定義はユーザ(自分自身かもしれません)の思いを反映しているものとします1

仕様

仕様とはソフトウェアのあるべき姿です。ここでは個々人の情報を以下のstruct personという構造体を使って扱うものとします。

struct person {
  char *name;
  int age;
}

ここでageは必ず0以上の数値にしなければならないとします。

ここからはstruct personという構造体の配列をソートするsort()という関数の仕様を次のように定義したとします。

  • インターフェースはint sort(struct *person[] a, int len)
  • 配列aageフィールドに基づいて昇順にソートする。ソートされた新しい配列を作るのではなくaの中身をそのままソートする。呼び出しが成功した場合は呼び出し後のaはa[0]->age <= a[1]->age <= ... <= a[len - 1]->ageとなる
  • 引数の意味
  • a: ソート対象の配列
  • len: aの長さ。1以上
  • 戻り値
  • 0: 成功
  • 1: 失敗。age0未満のときやlen1未満のとき

仕様の誤り

ここでsort()関数の仕様が前節において述べたものではなく次のようなものだったとします。もとの仕様と違うところは太字で書いています。

  • インターフェースはint sort(struct *person[] a, int len)
  • 配列aageフィールドに基づいて降順にソートする。ソートされた新しい配列を作るのではなくaの中身をそのままソートする。呼び出しが成功した場合は呼び出し後のaa[0]->age >= a[1]->age >= ... >= a[len - 1]->ageとなる
  • 引数の意味
  • a: ソート対象の配列
  • len: aの長さ。1以上。len0未満の場合の動作は未定義
  • 戻り値
  • 0: 成功
  • 1: 失敗。age0未満のときやlen1未満のとき

要件によるとsort()関数は配列aを昇順にソートすべきでしたが、ここでは降順にソートしてしまっています。このように要件に沿っていない場合は開発者が作った仕様が誤っているという問題が発生したといえます。誰に責任があるかは契約次第なのですが、ユーザが仕様にOKを出して全責任を負う、という契約であればユーザの責任ですし、開発者が仕様を自由に決めてよくて、かつ、開発者が責任を負うという契約の場合は開発者の責任となります。

仕様の誤りには他にも次のようなものが考えられます。

  • インターフェースはint sort(struct *person[] a, int len)
  • 配列aageフィールドに基づいて降順にソートする。ソートされた新しい配列を作るのではなくaの中身をそのままソートする。呼び出しが成功した場合は呼び出し後のa[]a[0]->age < a[1]->age < ... < a[len - 1]->ageとなる
  • 引数の意味
  • a: ソート対象の配列
  • len: aの長さ。1以上。len0未満の場合の動作は未定義
  • 戻り値
  • 0: 成功
  • 1: 失敗。age0未満のときやlen1未満のとき

こちらは個々人の年齢が同じ場合に仕様を満たせないため、これも仕様が誤っていると言えます。

実装

ではsort()関数の実装について考えます。ここでいう実装とはsort()関数を仕様に定められた通りに動くようにコード化することです。仕様を満たしさえすればどういう実装にしてもかまいません。なお、ここでは実装のコードそのものは重要ではないので考えません。

実装の誤り

sort()関数の実装が、a[]を降順にソートしてしまうものだった場合はどう考えればいいでしょうか。これは実装が仕様を満たしていないので、開発者による実装の誤りが発生した問題と言えます。責任の所在については、たとえばユーザが仕様にOKを出して全責任を負う、という契約であればユーザの責任ですし、開発者が仕様を自由に決めてよくて、かつ、開発者が責任を負うという契約の場合は開発者の責任となります。

使用方法の誤り

sort()関数の仕様も実装も正しいとします。ではこれで問題が発生しないかというとそんなことはありません。たとえばユーザがデータ入力を間違えており、a[]の中にageが-10の要素があったとします。この場合、sort()関数はエラーを示す1を返します。ここでユーザが「なぜソートされていないんだ、これは問題だ!バグだ!」ということはよくあるのですが、これはsort()関数自体は仕様通りに1を返しているだけなのでユーザが使用方法を誤っているといえます。

実装依存のふるまい

sort()関数の実装に使われるソートアルゴリズムには安定ソートと不安定ソートという二種類のものがあります。これらの意味については本記事のスコープを外れるのでwikipediaの記事などをご覧ください。sort()関数の仕様を見ると、この関数が安定ソートなのかどうかということは定められていません。このような場合に実装においてどちらの種類のアルゴリズムにするかは開発者にゆだねられています。

ここでユーザが「ソートといえば安定ソートだろ」という想定でこのソフトウエアを使って思い通りにならなかった場合に「これは問題だ!バグだ!」ということがありますが、これについては安定ソートか否かは仕様によって未定義なので責任は通常ユーザにあります。

バージョンアップと実装依存

たまたまsort()関数が安定ソートであり、かつ、ソフトウェアのバージョンアップに伴いsort()関数の実装が変わって不安定ソートにする場合はどのように扱えばよいでしょうか。これについては考え方が色々ありますが、原理原則からいうと未定義の動作がどうなろうとこれは問題ありません。ただし特別な理由がない限りこういうことは避けるほうがよいでしょう。文化によってはこの手の変更が決して許されないこともあります。

おわりに

ここまでみてくださったかたがたに感謝いたします。これを読み切ったかたの多くは何らかの形で本記事冒頭で述べたような状況に遭遇したことがあるのではないかと思います。それが作る側であったとしても使う側であったとしても、言葉の定義があやふやなことによる不幸な話の食い違いがこの記事によって多少なりとも減ることを祈ります。


  1. とくにユーザと開発者が違う人の場合に要件定義がそもそも間違っていることも多々ありますが、それについては割愛します

APIとかABIとかシステムコールとか

はじめに

本記事はLinux環境における次のようなことをざっくり理解するための記事です。

この手の情報はググればwikipediaやらにいろいろ情報が載ってるんですが、初心者が理解するには細かいことまで書かれすぎていて、かつ、それぞれの関係がわかりにくいです。なので、用語を逐一解説するのではなく、ありがちな質問のQAという形をとりました。人によって用語の意味の揺らぎがあったりするんですが、私の解釈ということで。あからさまに間違っていたら指摘していただけると嬉しいです。

これを書こうと思ったきっかけは、以前こんなtweetを見かけたことです。それから「そういえば最近使われる言語はコードやデータがどういうバイナリに落ちるかが見えないものが多いので、この手のことはあんまり知られてないなかなー、知ってると楽しいんだけどなー」と思ったことです。

APIって何?

APIは、ソースコードレベルで関数(OOPLのメソッドも含む)やデータ(OOPLのクラスも含む)の仕様を規定したものです。それらの関数やデータがどのようなバイナリデータとして表現されるかは気にしません。

ここ最近ではプログラミング言語APIだけではなく、webサービスにリクエストを出すときにどういうURLにどういうリクエストを出すとどういうレスポンスが得られるかを規定したWeb APIのほうがなじみがあるかもしれません。

ソースをもとにAPIを説明します。以下のようなCソースを書くとします。

int plus(int x, int y) {
  return x + y;
}

int main(void) {
  int a = 1;
  int b = 2;
  ..
  plus(a, b):
  ..
}

このときplus()のAPIは「第一引数にint型の値、第二引数にint型の値を渡すと、それを足し合わせたint型の値を返す」です。繰り返しになりますが、CPUのアーキテクチャやCコンパイラの実装に依存するエンディアンやintのバイト数などは気にしないです。

ABIって何?

ABIはバイナリレベルで「関数(OOPLのメソッドも含む)やデータ(OOPLのクラスも含む)の仕様を規定したものです。エンディアンとかデータのサイズも気にします。もっというと前節に記載したソースでいうとplus()呼び出し時にアセンブリ言語レベルで各引数をどういうアドレスに配置してからどういう命令を呼び出すかという呼び出し規約も気にします。

呼び出し規約については(とくにx86_64アーキテクチャの)アセンブリ言語を若干でも知っている人は以下の情報を見るとなんとなくわかるかと思います。

ABIはたとえば次のようなときに姿を現します

  • バイナリ形式ライブラリの関数を別のアプリないしライブラリから呼び出すとき
  • プログラムから使うプロトコルのバイトオーダーが指定されているとき

APIを叩く」ってどういう意味?

APIに従って関数を呼ぶ」ことを意味するスラングです。あまり深く考えなくてもいいです。

POSIX APIシステムコール呼び出しの違い

POSIX APIPOSIXと呼ばれる規格において定義されている、主にUNIX系のOS間で移植性を高めるために作られたC言語関数のAPIセットです。linuxPOSIXに準拠していませんが、POSIXに定義されているAPIはおおよそ備えています。Ubuntuにおいてはmanpages-posixをインストールしてman 3 readというコマンドを実行するとPOSIX APIとしてのread()の仕様が読めます。

システムコールはプログラムからハードウェアを操作したいなどの要求をカーネルに依頼する方法です。こちらはAPI(たとえばman 2 readとするとread()システムコールの定義を調べられる)と共にABIも決まっています。

通常はレジスタないしメモリ上の所定の位置に引数の値を書き込んでから特殊なアセンブリ命令を呼び出す、という方法で呼びます1

POSIX APIのうちのカーネルを呼び出す必要がある関数についてはABIに基づいてシステムコールを呼び出す必要がありますが、関数名とシステムコールの名前が一対一対応している必要はありません。たとえばglibcにおいてはfork()関数を呼び出すと内部的にはclone()システムコールを呼び出します。POSIX APIとしてfork()の仕様に書いてあることが実現できたら、内部的にどのシステムコールを使うかという実装詳細はなんでもいいのです。

システムコールC言語からしか直接呼び出せないの?

「直接」の定義にもよりますが、実は私の意見は「アセンブリ言語以外のC言語を含むあらゆる言語は直接システムコールを呼び出せない」です。

C言語においてもシステムコールを呼び出すときは内部的にはアセンブリ言語を使っています。例えばglibcにおいてはプログラマglibcのread()を呼び出すと、この関数の中ではインラインアセンブラという機能を使ってアセンブリ言語によってシステムコールを呼び出しています。あるいはアセンブリ言語で書かれたシステムコールを呼び出す関数を含んだライブラリを呼び出すという手もあります。こういう方法を「直接」呼び出していると思うかどうかは人によるでしょうが、上述の通りわたしはそう思いません。

C言語以外の「直接システムコールを呼び出せる」と言われている言語においても最終的には上記と似たような方法を使っています。

あるプログラミング言語から他のプログラミング言語の関数はどうやって呼び出すの?

呼び出し元の言語と呼び出し先の言語の間を繋ぐなんらかの仕組みを使います。たとえばCとpythonをつなぐ方法は次の通り。

なんで低レイヤプログラミングだとCがよく使われるの?

ここは人によって様々でしょうが私が思う理由は次の通り。

  • プログラマがメモリをほぼ剥き出しの形で扱える(裏を返せば「扱わなくてはいけない」)2
  • 異なるコンパイラ、異なるバージョン間でのABI互換性が高い
  • 言語仕様が小さいため、処理系が比較的作りやすく、かつ、メモリ消費量も小さい
  • 低レベルのAPI規格はPOSIX APIのようにC言語用のものが多い
  • ある言語から別言語を呼び出すためにCを中継することが多い(前節を参照)
  • 十分に枯れており、周辺ツールがたくさんある

おわりに

気が向けば次のようなことを扱った続編を書くかもしれません。

  • 互換性とは何か
  • ライブラリのバージョニング
  • ELF symbol versioning

  1. アセンブリ言語レベルの命令を使わなくても所定のメモリに書き込めばシステムコールを呼び出せる、というような実装も考えられますが、それは置いときます。

  2. このことよりC言語は「高級アセンブリ言語」と呼ばれることがある

linuxカーネル内部インターフェースの変更例

はじめに

Linuxカーネル(以下カーネルと表記)の外部ユーザ空間とのインターフェースはシステムコールが増えることはあっても既存のものが変更されることはほとんどなく、極力互換性が保たれるようになっています。しかしカーネル内部のインターフェースはめまぐるしく変わります1。本記事ではその一例として、カーネル内で一定時間後に所定の処理を呼び出すタイマーという機能のインターフェースが変更された話、およびその影響について紹介いたします。

何もしてないのにビルドできなくなった

筆者が昔々、およそ8年前書いた以下のカーネルモジュールのコードを本日カーネルv4.18のモジュールとしてビルドすると★★★と書いた行でエラーが出ました。

#include <linux/module.h>
#include <linux/timer.h>

MODULE_LICENSE("GPL v2");
MODULE_AUTHOR("Satoru Takeuchi <satoru.takeuchi@gmail.com>");
MODULE_DESCRIPTION("timer kernel module: oneshot timer");

struct timer_list mytimer;

#define MYTIMER_TIMEOUT_SECS    10

static void mytimer_fn(unsigned long arg)
{ 
        printk(KERN_ALERT "10 secs passed.\n");
}

static int mymodule_init(void)
{
        init_timer(&mytimer);                                     ★★★
        mytimer.expires = jiffies + MYTIMER_TIMEOUT_SECS*HZ;
        mytimer.data = 0;
        mytimer.function = mytimer_fn;
        add_timer(&mytimer);

        return 0;
}

static void mymodule_exit(void)
{ 
        del_timer(&mytimer);
}

module_init(mymodule_init);
module_exit(mymodule_exit);

エラー内容は「init_timer()という関数は存在しない」というものでした。この関数はタイマーを初期化するためのものです。上記コードを書いた時点では古いカーネルについてビルドは成功していたことから、この古いカーネルからv4.18までのある時点で当該関数が削除されたということが言えます。では、どういう理由でどのような流れでこうなったかをこれから紐解いていきましょう。

init_timer()が消えた理由

結論からいいますと、カーネル内のinit_time()を使っていた箇所はすべて新しいtimer_setup()という関数を使うように置き換えられています。その上でカーネル内部でユーザがいなくなったinit_timer()は削除されました。

事の発端はinit_timer()のインターフェイスセキュリティホールを生みやすいなどの色々問題があったことからtimer_setup()を追加するパッチが提案されて、それが公式カーネルにマージされたことです。

init_timer()とtimer_setup()の違いはざっくりいうと次の通りです。

  • タイマーが時間切れになったときに呼び出されるコールバック関数を、タイマーの初期化関数(timer_setup())を呼ぶときに引数として設定するようになった
  • コールバック関数の引数がタイマーを表すstruct timer_list型のデータのdataフィールドではなく当該データそのものへのポインタになった

コードを使って違いを説明します。タイマーを登録してから10秒後にmytimer_fn()を呼び出したいときにはinit_timer()は次のように使います。

...
struct timer_list mytimer;
...
{
        ...
        init_timer(&mytimer);
        mytimer.expires = jiffies + 10*HZ; // 10秒後にタイマーを起動させる。jiffiesは現在時刻を指す
        mytimer.data = 0;                  // 下記関数が呼び出されたときの引数として渡される値
        mytimer.function = mytimer_fn;     // コールバック関数
        add_timer(&mytimer);
        ...
}
...
static void mytimer_fn(unsigned long arg)
{ 
        ...
}

これに対してtimer_setup()は次のように使います。

...
struct timer_list mytimer;
...
{
        ...
        timer_setup(&mytimer, mytimer_fn, 0);  // 第三引数にはフラグを設定。ここでは説明を省略
        mytimer->expires = jiffies + 10*HZ;
        add_timer(&my_timer);
        ...
}
...
static void mytimer_fn(struct timer_list *t)   // タイマーが切れたときにはtはmy_timerを指す
{ 
        ...
}

このようなときにlinuxは過去のソフトウェアに多かった「今後はinit_timer()ではなくtimer_setup()を使ってね、すでにinit_timer()を使っているコードはそのまま動くよ」というやりかたではなく、「既存のinit_timer()呼び出しを全てtimer_setup()に全部置き換えた上でinit_time()を丸ごと削除するぜ」という大規模なリファクタリングをすることがよくあります。今回のタイマーの初期化についてはリファクタリングをする方向に舵が切られたというわけです。

移行は一度にやると間違いが発生したときのトラブルシューティングが大変なので、注意深く段階的に行われました。移行後は前述のようにinit_timer()は新たなユーザーが現れないように削除されました

細かい置換の過程に興味のあるかたはinclude/linux/timer.hkernel/time/timer.cをgit blameで調べるなどして深掘りしてみてください。みなさんが今後ご自身のコードをリファクタリングするときの参考になるかと思います。

移行による影響

移行による影響度はみなさんに関係のあるカーネル関係のコードが公式のカーネルソースにマージされているかどうかによって大きく異なります。マージされている場合は変更を施した人か、あるいはサブシステムメンテナなどの、そのコードに責任のある人が関連コードを変更します。それに対してマージされていない独自パッチや独自モジュールなどは自分で全部修正しなければいけません。後者の典型例は本記事の最初に述べた筆者独自のモジュールです。

カーネル関連コードを書く場合は公式カーネルにマージするほうが好ましい(ことが多い)理由の一つがこのようなインターフェースの変更などに追従するコストが減ることです。筆者のコードの場合は節題にした「何もしてないのにビルドできなくなった」ではなく、「何もしなかったからビルドできなくなった」というわけです。

おわりに

本記事ではlinuxカーネルの外部インターフェースは保たれるが内部インタフェースは保たれないということ、および、インターフェース変更による影響を最小化するにはコードを公式カーネルにマージしておく必要があることを述べました。今後みなさまがカーネルおよびカーネルモジュールの開発をするときの助けになれば幸いです。


  1. 詳細については公式ドキュメントをご覧ください

Socionext SC0FQAA-BはNUMAか否か

はじめに

本記事はLinuxのプロセススケジューラから見たSocionext SC2A11の続きです。Socionext SC0FQAA-B(以下「本マシン」と記載)は24コアのSC2A11を1つ搭載しています。このマシンがNUMAかどうか、言い方を変えるとSC2A11内の各コアがすべてのメモリに等速でアクセス可能かどうかを調査しました。

結論

本マシンはNUMAではないと推測しました。

linuxからの情報取得

まず、arm64はNUMA構成をサポートしています。LinuxのDevice Treeのドキュメントによると、Device TreeでNUMAを表現するには、各デバイスの定義内のnuma-node-idというトークンによってデバイスが所属するNUMA IDを定義します。

しかし、SC0FQAA-BのDevice Treeにはこのトークンは見つかりませんでした。

$ find /sys/firmware/devicetree -name numa-node-id
$

ここで終わりかというとそうではなくて、ハードがNUMAとして見せていないにも関わらず、実測してみると実質NUMAだと明らかになる、というのは珍しい話ではありません。

実験

使用するプログラム

データ採取には次のような仕様のプログラムを使います。

  1. コア0上でL3キャッシュを超えるサイズのバッファ(ここでは16MB)を取得する
  2. 以下を24コアのすべてにおいて繰り返す。コア番号をnとする
  3. 2-1. プロセスをコアnに移動させる
  4. 2-2. 上記バッファに所定の回数アクセスすることによってメモリアクセス速度を計測

処理1において取得したメモリはプログラムの実行中に物理アドレスが移動することはないため1、処理2-1においてコアを移動した場合にバッファへアクセス速度が変化すれば、おそらく本マシンはNUMAだろう、というわけです。

これを実装したのが次のnuma.cプログラムです。

#define _GNU_SOURCE
#include <sched.h>
#include <unistd.h>
#include <sys/mman.h>
#include <time.h>
#include <stdio.h>
#include <stdlib.h>
#include <err.h>

#define CACHE_LINE_SIZE 64
#define NLOOP           (64*1024*1024)
#define BUFFER_SIZE     (16*1024*1024)
#define NCORE           24

#define NSECS_PER_SEC   1000000000UL

static inline long diff_nsec(struct timespec before, struct timespec after)
{
        return ((after.tv_sec * NSECS_PER_SEC + after.tv_nsec)
                - (before.tv_sec * NSECS_PER_SEC + before.tv_nsec));
}

static void setcpu(int n)
{
        cpu_set_t set;
        CPU_ZERO(&set);
        CPU_SET(n, &set);
        if (sched_setaffinity(getpid(), sizeof(cpu_set_t), &set) == -1)
                err(EXIT_FAILURE, "sched_setaffinity() failed");
}

int main(int argc, char *argv[])
{
        char *progname;
        progname = argv[0];

        register int size = BUFFER_SIZE;

        setcpu(0);
        char *buffer;
        buffer = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
        if (buffer == (void *) -1)
                err(EXIT_FAILURE, "mmap() failed");

        int i;
        for (i = 0; i < NCORE; i++) {
                setcpu(i);

                struct timespec before, after;
                clock_gettime(CLOCK_MONOTONIC, &before);
                int j;
                for (j = 0; j < NLOOP / (size / CACHE_LINE_SIZE); j++) {
                        long k;
                        for (k = 0; k < size; k += CACHE_LINE_SIZE)
                                buffer[k] = 0;
                }
                clock_gettime(CLOCK_MONOTONIC, &after);
                printf("%d\t%f\n", i, (double)diff_nsec(before, after) / NLOOP);
        }





        if (munmap(buffer, size) == -1)
                err(EXIT_FAILURE, "munmap() failed");
        exit(EXIT_SUCCESS);
}

結果

結果は次の通りです。第一フィールドがデータを採取したコア、第二フィールドがメモリアクセス処理に所要時間です。

0       37.300696
1       37.511301
2       37.320645
3       37.479546
4       37.280271
5       37.298240
6       37.207461
7       37.285339
8       36.824337
9       36.862199
10      36.926653
11      36.867224
12      37.064979
13      37.011814
14      37.041438
15      37.074635
16      37.414566
17      37.373740
18      37.375946
19      37.423050
20      37.292513
21      37.323017
22      37.154486
23      37.190995

次に示すのは、コア0上でバッファにアクセスする測定を24回繰り返した結果です。

36.993787
36.986646
37.254620
37.231557
37.587849
37.582042
37.450369
37.387533
37.487312
37.452216
37.501526
37.556768
37.139728
37.283043
37.169193
37.389888
37.746065
37.841875
37.559928
37.581090
37.769728
37.680080
37.567870
37.194819

いずれも37秒前後1秒程度の差に収まっているため、アクセス速度にあまり違いはないといっていいでしょう。つまり、ある物理メモリ(numa.cにおいて取得したバッファ)には24個いずれのコアからもおおよそ等速でアクセスできたということです。numa.cの処理1においてバッファを採取する際のコアを1-23に変えても結果は同じでした。これによって本マシンは恐らくNUMAではなかろうと推測しました。


  1. メモリが断片化してきた場合はコンパクションという機能によって物理メモリが移動することがありますが、今回はほとんどのメモリが空いている状態なのでその心配はありません。

Ryzen1800xでキャッシュのスラッシング

ほそく

  • 2019/2/19 追記: 当初"false sharing"と書いていたのはすべて誤りで実際は"キャッシュのスラッシング"でした。すいません。

はじめに

本記事は、AMD社のRyzen1800x(以下1800xと記載)においてキャッシュのスラッシング(以下スラッシングと記載)が発生する様子を実験によって確かめた結果をまとめたものです。スラッシングとは、あるキャッシュメモリの内容を書き換えたとき、キャッシュメモリに保存されているデータの整合性を保つために、別のキャッシュメモリの内容を無効化する、というしくみが頻繁に繰り返されることです。

CPUの構成

1800xにはCCXと呼ばれる4コアを搭載したダイが2つ乗っています。ダイの中にはコアが4つ入っており、かつ、コアの中には2つのハイパースレッドが存在します。これを、Linuxが認識する16の論理CPUの番号と対応付けたのが次の表です。

論理CPU番号 コア番号 CCX番号
0,1 0 0
2,3 1 0
4,5 2 0
6,7 3 0
8,9 4 1
10,11 5 1
12,13 6 1
14,15 7 1

1800xにはL1(L1i,L1d),L2,L3という3階層のexclusiveキャッシュメモリを持っており、L2まではコア内で共有、L3は同じCCX内でだけ共有という構成になっています。キャッシュラインサイズは64バイトです。

それぞれのサイズと共有関係を示したのが次の表です

名前 サイズ[KB] 共有
L1d 32 同一コア内
L1i 64 同一コア内
L2 512 同一コア内
L3 8192 同一CCX内

実験プログラム

実験プログラムの仕様は次の通りです。

  • 二つの引数を受け取る
  • バッファサイズ[KB単位]
  • 下記2つのスレッドがバッファを共有するか否か(true/false)
  • 2つのスレッドが、それぞれ合計1GBのデータを書きこむ: バッファ内の全キャッシュラインへの書き込みを"1GB/(バッファサイズ/キャッシュラインサイズ)"回繰り返す

これを実装したのが次のソースです。

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <err.h>
#include <errno.h>
#include <stdbool.h>

#define TOTAL_BYTES     (1024*1024*1024)
#define CACHE_LINE_SIZE 64

static int buffer_size;

static void * thread_fn(void *arg)
{
        char *buf = (char *)arg;
        char *end = buf + buffer_size;

        int n = TOTAL_BYTES / (buffer_size / CACHE_LINE_SIZE);

        int i;
        for (i = 0; i < n; i++) {
                char *p;
                for (p = buf; p < end; p += CACHE_LINE_SIZE)
                        *p = 0;
        }

        return NULL;
}

static char *progname;

int main(int argc, char *argv[])
{
        progname = argv[0];

        if (argc != 3) {
                fprintf(stderr, "usage: %s <buffer size[KB]> <share>\n", progname);
                exit(EXIT_FAILURE);
        }

        buffer_size = atoi(argv[1]) * 1024;
        if (!buffer_size) {
                fprintf(stderr, "'buffer size' should be larger than 0: %s\n",
                        argv[1]);
                exit(EXIT_FAILURE);
        }

        bool share;
        if (!strcmp(argv[2], "true")) {
                share = true;
        } else if (!strcmp(argv[2], "false")) {
                share = false;
        } else {
                fprintf(stderr, "'share' should be 'true' or 'false': %s\n", argv[2]);
                exit(EXIT_FAILURE);
        }

        int ret;
        char *buf1;
        ret = posix_memalign((void *)&buf1, CACHE_LINE_SIZE, buffer_size * 2);
        if (ret) {
                errno = ret;
                err(EXIT_FAILURE, "posix_memalign() failed");
        }

        char *buf2;
        if (share)
                buf2 = buf1;
        else
                buf2 = buf1 + buffer_size;

        int i;
        pthread_t tid[2];
        for (i = 0; i < 2; i++) {
                void *arg;
                if (i == 0) {
                        arg = buf1;
                } else if (i == 1) {
                        arg = buf2;
                } else {
                        abort();
                }
                ret = pthread_create(&tid[i], NULL, thread_fn, arg);
                if (ret) {
                        errno = ret;
                        err(EXIT_FAILURE, "pthread_create() failed");
                }
        }
        for (i = 0; i < 2; i++) {
                ret = pthread_join(tid[i], NULL);
                if (ret)
                        warn("pthread_join() failed");
        }

        exit(EXIT_SUCCESS);
}

ビルド方法

$ cc -O2 -o false_sharing{,.c} -lpthread
$ 

実行方法

単純なコマンド発行は次の通りです。

$ ./false_sharing <buffer size> <share>

2スレッドが同じCCX上に配置して所要時間を計測する場合は次のようにします。

$ time taskset -c 0,4 <buffer size> <share>

2スレッドを別のCCX上に配置して所要時間を計測する場合は次のようにします。

$ time taskset -c 0,8 <buffer size> <share>

採取パターン

以下のパターンを全部網羅します。

  • バッファサイズ: L1dキャッシュの1/4(バッファがL1dキャッシュに収まる場合), L2キャッシュの1/4(バッファがL2キャッシュに収まる場合), L3キャッシュの1/4(バッファがL3キャッシュに収まる場合), L3キャッシュ*2(バッファがキャッシュに収まらない場合)
  • 2つのスレッドのCCX: 同じ、別

結果

採取したデータの1.x倍、あるいは数倍程度の差はここでは気にしません。数十倍の性能劣化が発生するスラッシングにのみ話を絞ります。なお、これから示す測定結果の数値の単位は秒です。

ケースA: バッファサイズがL1dのサイズのに収まる(32/4 = 8[KB])

プロセスを割り当てる論理CPU バッファ非共有 バッファ共有
0,4(同じCCX) 0.675 13.2
0,8(別CCX) 0.824 22.5

2つのスレッドのCCXが違う場合も異なる場合もL1を吹っ飛ばすスラッシングが発生していることがわかります。これによって数十倍のアクセス性能劣化が発生しています。

ケースB: バッファサイズがL2 cacheに収まる(512/4 = 128[KB])

プロセスを割り当てる論理CPU バッファ非共有 バッファ共有
0,4(同じCCX) 0.625 14.70
0,8(別CCX) 0.690 18.8

ケースAと同様、2つのスレッドが同じCCXの場合も、違うCCXにある場合もL2 cacheを飛ばすスラッシングが発生しています。

ケースC: バッファサイズがL3 cacheに収まる(8*1024/4 = 2MB)

プロセスを割り当てる論理CPU バッファ非共有 バッファ共有
0,4(同じCCX) 0.885 0.900
0,8(別CCX) 0.878 6.93

2スレッドが同じCCXにある場合は、バッファを共有していようと性能がほとんど変わりません。この理由は、このバッファサイズの場合はデータはほとんどL3キャッシュ上にあること、および、2スレッド間でL3キャッシュを共有しているためにスラッシングが発生しないことだと考えられます。私の知る限り、本記事執筆現在のIntelのCore系プロセッサにおいてはL3キャッシュを全コア間で共有するため1、このようなことが発生しないはずです。

2スレッドが別のCCXにある場合は、互いのL3キャッシュを吹っ飛ばし合うのでスラッシングが発生しています。

ケースD: バッファサイズがL3 cacheに収まらない(810242 [KB] = 16 [MB])

プロセスを割り当てる論理CPU バッファ非共有 バッファ共有
0,4(同じCCX) 9.13 14.5
0,8(別CCX) 8.87 7.96

いずれもケースA-Cにおいてスラッシングが発生した場合のような速度になっています。それぞれ、もともとデータがL3キャッシュに乗っていないため、キャッシュの吹き飛ばしによる影響が少ないためだと考えられます。

おわりに

細かい数値の大小について気になるところはいくらでもありますが、スラッシングの発生を確かめられたので、満足です。


  1. もし間違っていたらご指摘ください

WSL vs VM for 開発作業

はじめに

本記事はLinux Advent Calendar 2017の最終日、25日目の記事です。

みなさんはWindows Subsystem for Linux(WSL)というWindowsのソフトウェアをご存知でしょうか。これはUbuntu, openSUSE, あるいはSUSE Linux Enterpriseという3つのLinux distributionをWindowsから使えるという機能です。Microsoftストアから無償でインストール可能です。低レイヤの細かいところ(/procや/sysのファイルが少なかったり、ほとんどのデバイスファイルが無かったり)は違うのですが、おおよそ通常のLinuxディストリビューションと同様に使える優れものです。

本記事は、Linuxソフトウェア開発環境という目的に絞って、WSLと、Windows上で動作するVirtualBoxにインストールしたLinux(以下、VMと記載)を比較した結果を記載します。

比較項目

WSLもVMも、開発環境として使うためには、まずは導入コストが気になります。本記事では次のような導入コストに関する比較をしました。

  • インストール所要時間
  • 起動速度、および起動時のメモリ消費量

それに加えて、ソフトウェアを開発するにあたっての頻出操作に関する速度を比較しました。

  • ソース読み書きの体感速度
  • git checkoutの速度
  • タグジャンプファイル(cscope)作成速度
  • ビルド速度

比較結果の要約

  • 導入コストはWSLのほうがVMよりも低い
  • WSLは中小規模のソフトウェアの開発には問題なく使えるが、大規模ソフトウェアの開発は速度的な問題で厳しい。今後の改善に期待

データ採取環境

  • PCのスペック
  • CPU: Ryzen 1800x (8 core, HT off)
  • メモリ: 32GB
  • ストレージ: 512GB NVMe SSD
  • OS: Windows 10 Home Fall Creators Update(バージョン1709), ビルド16299.64
  • WSL: Ubuntu 16.04 (カーネルは4.4.0-43-Microsoft)
  • VMM: VirtualBox

  • VM(上記の実機上に構築)のスペック

  • VCPU数: 8
  • メモリ: 16GB
  • ストレージ: 20GB
  • OS: Ubuntu 16.04 (カーネルは4.10.0-38-generic)

比較結果

インストールの手間

WSLとVM, それぞれについて手作業でインストールして、その所要時間を計測しました。

WSL[分] VM[分]
3 21

WSLのほうがはるかに速いという結果が得られました。なお、WSLのインストール手順は次の通りです。

  1. Microsoftストアにおいて"Ubuntu"で検索
  2. インストールボタンを押す
  3. パスワード設定

VMのインストール手順についてはVirtual BoxのインストールからVMをインストールした後に起動開始するまでの時間を含めています。Virtual Boxが既に手元にある場合、Vagrantなどによって既製のVMを使う場合はまた話が変わってきますが、それは今回は考えないものとします。

起動コスト

起動所要時間、および、インストール直前とインストール直後のWindowsのメモリ消費量から求めたWSLとVMのメモリ消費量を示します。

項目 WSL VM
起動速度[秒] 1 15
起動直後のメモリ使用量[MB] 15 860

起動速度についてはVMがやや待たされるのに対してWSLはほぼ一瞬ですので、WSLのほうが有利です。メモリ使用量についても、VMは900MB弱使用するのに対してWSLはほとんど消費しません。筆者の環境はそれなりにメモリを積んでいますが、メモリ量が少ないマシンをお使いのかたにはWSLは魅力的な選択肢となるでしょう。

頻出操作の速度

これについては中小規模と大規模のソフトウェアという2つの軸で比較しました。中小規模のものとしてはソースの規模が6万行程度のmdadmを、大規模のものとしてはソースの規模が2,300万行程度のlinuxを使いました。それぞれみなさんが普段開発されるソフトウェアに近いほうの結果を見ていただければいいかと思います。

速度の測定については、ソースの読み書き速度(emacsを使用)については、かなりダサいのですが「使っていてもっさり感を感じるかどうか」という雑な指標です。いい方法が思いつきませんでした。すいません。

中小規模ソフトウェア(mdadm)

ソースの読み書き速度については両者において大した違いは出ませんでした。どちらも合格です。

それ以外については次のような結果が得られました。

項目 WSL VM
git checkout(バージョン3.4->4.0)[秒] 0.470 0.008
タグジャンプファイル作成[秒] 0.278 0.029
ビルド(8並列)[秒] 4.20 1.23

WSLのほうがVMよりも数倍から数十倍遅いという結果になりました。しかし絶対値が小さいために、WSLにおいても一瞬引っかかりを感じる程度でさしたるストレスは感じませんでした。これについてはWSLは十分に実用的だと判断しました。

大規模ソフトウェア(linux)

ソースの読み書き速度については、中小規模のソフトウェアの場合と同様、大した違いは出ませんでした。

それ以外については次のような結果になりました。

項目 WSL VM
git checkout(バージョン4.0->4.14)[秒] 270 5.5
タグジャンプファイル作成[秒] 86.0 39.7
ビルド(8並列)[秒] 439 169

中小規模のソフトウェア同様、数倍から数十倍遅いという結果になりました。しかし、絶対値が分単位なので、WSLにおいては非常に待ち時間が長いと感じました。個人的にはこういう用途にはVMを使いたいと思いました。

考察

大規模環境とみなしたLinuxにおけるgit checkout速度、タグジャンプファイル作成速度、およびビルド速度について、何が遅いのかを調査してみました。

git checkout

プロセスの処理か、システムコールか、待ちか

まずはtimeコマンドによって得られるreal, usr, sysの値をもとに、WSLの処理が遅い原因が次のうちにどこ(あるいは複数の組み合わせ)にあるのかを突き止めることにしました。

  • プロセス自身の処理時間
  • カーネルの処理時間
  • I/Oなどによる待ち時間
WSL VM
real 254 5.35
usr 4.70 3.59
sys 95.1 1.78

ユーザ空間の処理時間には大した違いはありません。WSLはユーザプロセスはコードの変換なしでネイティブ動作させており、システムコールだけをエミュレーションしているため、これは納得できる話です。カーネル内実行時間についてはWSLのほうがVMに比べて数十倍多かったです。カーネル内処理に余分に時間がかかっていることがわかります。

git checkoutは並列動作しないため、real - usr - sysの値がそのまま処理の待ち時間となります。

WSL VM
待ち時間[秒] 154.2 -0.01

VMの待ち時間が負数になっていますが、これは0の誤差範囲内の値だと考えてください

これを見ると、VMは待ち時間がほとんど無かったにも関わらず、WSLは全体の半分以上の時間が待ち時間だったことがわかります。

個々のシステムコールの調査

前節の結果を踏まえて、次は一体どんなシステムコール、およびその待ち時間に時間を要したのかを調査しました。調査にあたっては、各種処理をstrace -f -Tによってトレースを仕込んだ上で実行することによって、個々のシステムコールにかかる時間を測定しました。この方法はstrace自体のオーバーヘッドによって全体の所要時間は変わってしまうのですが、何に時間がかかったのかという、おおよその傾向は掴めます1

ここでは処理中に実行したシステムコールの中で、WSLにおける合計所要時間の一番長いものから5つについて、合計所要時間と一回当たりの平均所要時間を記載しました。VMにおける対応するデータも併記しました。

WSL VM
close()[秒] 188 0.584
write()[秒] 118 1.46
open()[秒] 33.2 0.889
unlink()[秒] 29.6 0.822
lstat()[秒] 29.6 4.71

上記のようなファイルアクセス周りのシステムコールに大きく時間がとられていることがわかりました。とくにclose()とwrite()の割合が多かったです。close()はファイルを閉じるだけという処理内容から見ると、時間がかかりすぎに見えます。write()については、VMはページキャッシュに書き込んですぐ戻ってきているように見えますが、WSLはそうなっているようには見えません。

なお、これらシステムコールの所要時間にはカーネルがCPUを使っていた時間だけでなく、その過程でI/Oの完了などを待っていた時間を含みます。このため、これらの所要時間の総和は全体の処理の所要時間(real)よりはるかに長くなるので、注意が必要です。

タグジャンプファイル作成

プロセスの処理か、システムコールか、待ちか

WSL VM
real 95.2 39.9
usr 48.7 48.3
sys 60.5 12.2

まずユーザ空間の処理時間はgit checkoutの場合と同様、大して変わりません。それに対してカーネル空間の処理時間は5倍程度WSLのほうが長かったです。

タグジャンプファイル作成処理は並列動作するので、real - (usr + sys)/8がコアあたりのおおよその平均待ち時間と考えられます。

WSL VM
コアあたりの平均待ち時間[秒] 81.5 32.3

git checkoutの場合とは異なり、VMについても待ち時間が発生していますが、WSLのほうがVMよりも数倍大きな値になりました。

個々のシステムコールの調査

WSL VM
futex()[秒] 412 175
write()[秒] 117 32.3
read()[秒] 18.5 21.1
lstat()[秒] 7.18 1.91
access()[秒] 6.44 2.08

futex()とwrite()が速度差の支配的因子であることがわかります。futex()は排他制御に用いるシステムコールです。それに加えてgit checkout()のときと同様、write()をはじめとするファイルアクセス処理に多くの時間を費やしていることがわかりました。ただし、VMの場合もwrite()はページキャッシュへのアクセスだけではおさまっていないようです。

ビルド

プロセスの処理か、システムコールか、待ちか

WSL VM
real 281 97.6
usr 604 605
sys 905 51.6

これまでと同様、ユーザ空間での実行時間は同じです。その一方、カーネルの実行時間はWSLがVMの18倍にのぼりました。

ビルド処理は並列動作するので、real - (usr + sys)/8をコアあたりの平均待ち時間と考えました。

WSL VM
コアあたりの平均待ち時間[秒] 92.3 15.5

両者共に長い待ち時間がありますが、平均待ち時間はWSLがVMの6倍強になっています。

個々のシステムコールの分析

WSL VM
read()[秒] 15500 3610
write()[秒] 2850 23.2
open()[秒] 970 0.00237
execve()[秒] 319 8.21
close()[秒] 288 75.7

これまでと同様、ファイルアクセス処理に多くの時間がかかっていることがわかりました。read()についてはどちらもストレージデバイスに読みに行っているように見えますが、WSLのほうがはるかに遅いです。write()については、VMは全てまたはほとんどがページキャッシュへの書き込みだけで復帰しているように見えますが、WSLに関しては多くが長時間待たされているらしく、100倍以上の速度差が出ています。

まとめ

ユーザ空間の処理速度はほとんど変わらないもののカーネル空間の処理の影響でWSLはVMよりも低速であることがわかりました。とくにファイルアクセス関連のものについては待ちになるケースが多いこと、かつ、待ち時間も長くなる傾向にあることがわかりました。

個々のシステムコールにおける所要時間のうちわけ(カーネル処理 or 待ち)や、タグジャンプファイル作成処理においてはread()速度が変わらない点など、深堀りできそうな要素はいくらでもあるのですが、とりあえず今日は力尽きたのでここまでです。

おわりに

いかがでしたでしょうか。本記事における調査はあくまで筆者の独断と偏見に基づくものなので、みなさまも他に気になる点があれば、色々と性能測定してみると楽しいと思います。

筆者はWSLはネイティブ環境あるいはVM上にインストールしたLinuxの代替品ではなく、それらよりも用途が限定されるものの、より気軽に使えるソフトウェアというとらえかたをしています。今のところは次のような用途に使っています。

  • 中小規模のソースを読む
  • やっつけスクリプトなどの短いソースの読み書きと動作確認
  • 文書作成
  • sshによるリモート接続
  • 電卓(python -c)

かなりできることは少ないという印象ですが、実際に使ってみると、これだけでもかなり嬉しいものです。

個人的にはWSLはファイルシステムアクセス速度が速くなれば導入コストが安い、かつ、開発に必要な各種処理も高速な、理想的な開発環境になると思っています。今後の改善に期待です。なお、Microsoft社も(当然)この問題を認識しているらしく、将来のバージョンでは改善予定があるようです


  1. WSLにおける、もっとよい性能測定の方法をご存知のかたがいれば教えてほしいです。