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における、もっとよい性能測定の方法をご存知のかたがいれば教えてほしいです。

brd: メモリ上に作成するブロックデバイス

はじめに

本記事はlinuxにおいてメモリ上に作成するブロックデバイスであるbrdというカーネルの機能について説明します。

用途

ブロックデバイスを直接しようする各種ファイルシステムやDB、partedなどのパーティショニングツールのテストが主な用途でしょう。とくに次のような場合には便利でしょう。

  • バックエンドがストレージデバイスであるときには時間がかかる加速試験の実行を高速化したい
  • 巨大なブロックデバイスが扱えるかを確認したい(後述するようにbrdにはブロックデバイスのサイズ制限が事実上無い)

使い方

機能が使えるかどうかの確認

まずはお使いのカーネルでbrdがサポートされているかを確認する必要があります。以下コマンドを実行した結果、"CONFIG_BLK_DEV_RAM=[ym]"という行が出てくれば使えます。そうでなければ当該機能が有効になっているカーネルを用意する必要があります(カーネルビルドについては本記事の対象外です)。

$ grep "^CONFIG_BLK_DEV_RAM=" /boot/config-$(uname -r)

例えばUbuntu 16.04のカーネルにおいてはモジュールとして提供されています(CONFIG_BLK_DEV_RAM=m)。

これ以降はbrdがモジュールとして提供されているとして話をします。

初期化方法

次のコマンド発行によってbrdを初期化します。

$ sudo modprobe brd

成功した場合は次のように/dev/ram*というブロックデバイスができます。

$ ls /dev/ram*
/dev/ram0  /dev/ram1  /dev/ram10  /dev/ram11  /dev/ram12  /dev/ram13  /dev/ram14  /dev/ram15  /dev/ram2  /dev/ram3  /dev/ram4  /dev/ram5  /dev/ram6  /dev/ram7  /dev/ram8  /dev/ram9

使用

/dev/ram*は全て通常のブロックデバイスと同様に使えます。たとえば以下は/dev/ram0上にBtrfsを作成する例です。

$ sudo mkfs.btrfs /dev/ram0
btrfs-progs v4.13.1
See http://btrfs.wiki.kernel.org for more information.

Performing full device TRIM /dev/ram0 (1.00GiB) ...
Label:              (null)
UUID:               638d68c2-ff24-44f2-9789-37309ba8cce7
Node size:          16384
Sector size:        4096
Filesystem size:    1.00GiB
Block group profiles:
  Data:             single            8.00MiB
  Metadata:         DUP              51.19MiB
  System:           DUP               8.00MiB
SSD detected:       no
Incompat features:  extref, skinny-metadata
Number of devices:  1
Devices:
   ID        SIZE  PATH
    1     1.00GiB  /dev/ram0

$ 

設定変更方法

brdが作成するデバイスの数、およびサイズはカーネルビルド時の設定によって異なります。たとえばUbuntu 16.04の場合はデバイスの数は16個、デバイスのサイズは64MBです。それぞれrd_nr, rd_rd_sizeというモジュールパラメタによって変更可能です。rd_sizeはKB単位ですデバイスの数を4個、デバイスのサイズを1GBにする例を示します。

$ sudo modprobe brd rd_nr=4 rd_size=$((1024*1024))
$ ls /dev/ram*
/dev/ram0  /dev/ram1  /dev/ram2  /dev/ram3       # デバイス数は4
$ LANG=C sudo parted /dev/ram0 p
Error: /dev/ram0: unrecognised disk label
Model: Unknown (unknown)
Disk /dev/ram0: 1074MB                           # サイズは1GB
Sector size (logical/physical): 512B/4096B
Partition Table: unknown
Disk Flags:

rd_sizeはシステムに搭載したものよりも大きいものを設定できます。brdは初期化時にデバイスに必要なメモリをすべて確保するのではなく、オンデマンドにメモリを獲得します。具体的には、内部的にはデバイスの領域をページ(x86_64の場合は4KB)単位に区切って管理しており、各ページに対して最初にアクセスしたときにページごとにメモリを獲得します。

終了方法

brdが作ったデバイスが不要になったら次のコマンドによってモジュールを削除します。このときbrdが使用していたメモリを解放します。

$ sudo modprobe -r brd

他の類似機能との違い

tmpfsとの違い

brdとtmpfsはメモリベースであることは同じなのですが、tmpfsはユーザにファイルシステムを提供するのに対してbrdはブロックデバイスを提供するというところが違います。つまりレイヤが異なります。

ファイルのループバックマウントとの違い

何らかのファイルシステム上に作成したファイル上にファイルシステムを作成してループバックマウントすることによって、ファイルシステムのテストはできます。sparseファイルを作れば大容量ファイルシステムも作成できます。しかしループバックマウントにおいてはファイルシステムにアクセスするごとにストレージの容量を消費してしまうという点が異なります。

おわりに

開発状況については、brdはかなり枯れた機能であり、2,3か月に一度のlinuxのバージョンアップごとに数個パッチが入る程度です。

機能がシンプルなせいもあり、brdのソース(drivers/block/brd.c)はたったの600程度なので、ブロックデバイス層の最も単純な実装を理解したいというかたは見てみると面白いでしょう。

Goでdirect I/O

はじめに

本記事はGoでlinuxのdirect I/Oを使う方法について書いたものです。A Tour of Goを全部終えた人なら読めると思います。

ソフトウェアバージョン

  • OS: Ubuntu 16.04
  • kernel: 4.4.0-83-generic
  • go: 1.9

きっかけ

Linuxのdirect I/Oは、read/write時用に使うバッファの先頭アドレスおよびサイズを、ストレージのブロックサイズの倍数(通常512バイトないし4096バイト)にアライメントする必要があります。Goの標準ライブラリ内には次のようにO_DIRECTが定義されていますが、この条件に合致するバッファをどうやってとるのかが気になったので調べました。

const (
...
        O_DIRECT                         = 0x4000
...

結論

仕様上direct I/Oの要求を満たすバッファを獲得できないため、自前でunsafeパッケージを使ってバッファをアライメントしたり、後述のdirectioパッケージなどを使うなりする必要がありそうです。

仕様の確認

仕様上は次のように、配列のアライメントは、配列の各要素のアライメント以上のものが保証されていませんでした。

The following minimal alignment properties are guaranteed:

* For a variable x of any type: unsafe.Alignof(x) is at least 1.
* For a variable x of struct type: unsafe.Alignof(x) is the largest of all the values unsafe.Alignof(x.f) for each field f of x, but at least 1.
* For a variable x of array type: unsafe.Alignof(x) is the same as the alignment of a variable of the array's element type.

それに加えて、アライメントを考慮したメモリアロケーションの仕組みが標準ライブラリにあるかどうかを確認しましたが、こちらも該当するものはなかったです。

directioライブラリ

前節で述べた通り標準ではdirect I/Oに必要なバッファをとる確実な方法が無いのですが、上述したdirect I/O用のライブラリ、directioをweb上で見つけました。

directioライブラリはunsafeライブラリを活用して、バッファの獲得時に内部的に余分な領域をとることによって、direct I/O用バッファの先頭アドレスをアライメントしています。

// alignment returns alignment of the block in memory
// with reference to AlignSize
//
// Can't check alignment of a zero sized block as &block[0] is invalid
func alignment(block []byte, AlignSize int) int {
    return int(uintptr(unsafe.Pointer(&block[0])) & uintptr(AlignSize-1))
}

// AlignedBlock returns []byte of size BlockSize aligned to a multiple
// of AlignSize in memory (must be power of two)
func AlignedBlock(BlockSize int) []byte {
    block := make([]byte, BlockSize+AlignSize)
    if AlignSize == 0 {
        return block
    }
    a := alignment(block, AlignSize)
    offset := 0
    if a != 0 {
        offset = AlignSize - a
    }
    block = block[offset : offset+BlockSize]
...

glibcposix_memalign()も内部的には似たようなこと(だいぶ複雑ですが)をしています。興味のあるかたはglibcのソースを見てください。

おわりに

unsafeライブラリやdirectioライブラリを使わない、もっといい方法を知っているかたがいれば教えてほしいです。

Windows Subsystem for Linuxとguest/native Ubuntuの性能をざっくりと比較

(2017年12月25日注) 本記事より新しいバージョンのWindowsについての類似記事を書きましたので、本記事のかわりにそちらをごらんください。

はじめに

わたしの手持ちのノートPCにはWindows10が入っています。もっぱらWindows Subsystem for Linux(WSL, いわゆるbash on Ubuntu on Windows)上で作業をしています。次のような小規模なプログラムの実行については全く問題無く使えており、WSLが無かったころに比べると、Windows端末を使った開発が非常に楽になりました。

  • ちょっとしたプログラムの動作確認
  • sshでリモートの開発マシンにログイン
  • 小規模ソースの読解(タグジャンプ、grep検索、git操作など)

その一方で、次のような操作においては、そこそこのスペックのマシンのはずなのに、やや「もっさり感」を感じることがあります。

  • 長いshell script。./configureのようにプロセス実行ごとにログが出力される場合は、ログを目で追えるほどになることも
  • 大規模ソース(具体的には数GBの大きさになるlinuxなど)の読解(タグジャンプ、grep検索、git操作など)。全てのデータがページキャッシュに乗る程度の大きさなのに、やけに遅く感じる

そこで、次の3つの環境の性能測比較によって、上記のような場合に何が性能ネックになっているかをざっくりと確かめました。

  • Windows10(バージョンは1703, OSビルドは15063.540)上のWSL
  • 上記マシン上のVirtual Box guestのUbuntu16.04(以下guest Ubuntuと記載)
  • 別のnativeマシン上のUbuntu 16.04(以下native Ubuntuと記載)1

それぞれのマシンにおいて性能測定時に性能測定プログラム以外の負荷はほぼかかっておらず、かつ、I/Oに関する測定においては、データは全てページキャッシュに乗るほどの空きメモリがあります。

マシンスペック

名前 WSL guest Ubuntu native Ubuntu
カーネル 4.4.0-43-Microsoft 4.10.0-33-generic 4.10.0-27-generic
CPU core m3-6Y30 1VCPU切り出し Ryzen 1800X
RAM 8GB 2GB 32GB
storage SanDisk X300S SD7TB3Q-256G-1006(256GB: read 520MB/s, write 470MB/s) Crucial CT275MX300SSD1(256GB: read 530MB/s write 500MB/s)
filesystem lxfs btrfs btrfs

WSLとnative Ubuntuの比較においては両者のスペックがかなり異なること、および後述の性能測定方法の精度が粗いことより、両者の性能値を厳密に比較してもあまり意味がありませんので、ざくっとした比較しかしていません(それしかできません)。どの程度の精度での比較が妥当かというのは本文中で適宜説明をします。

プロセス/スレッド生成コスト

次のようなプログラムを使ってプロセス/スレッド生成時のコストを採取しました。

#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <signal.h>
#include <pthread.h>
#include <time.h>
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <err.h>

char *progname;
static int concurrency;

static void show_usage(void)
{
        fprintf(stderr, "usage: %s <p|t> <n>\n"
                "\tmeasure process/thread creation time.\n"
                "\t<p|t>: 'p' for process and set 't' for thread\n"
                "\t<n>: concurrency\n",
                progname);

        exit(EXIT_FAILURE);
}

static void measure_process_creation_time(void)
{
        pid_t *pids;
        pids = malloc(sizeof(pid_t) * concurrency);
        if (pids == NULL)
                err(EXIT_FAILURE, "malloc() failed");

        struct timespec begin, end;

        if (clock_gettime(CLOCK_MONOTONIC_RAW, &begin) == -1)
                err(EXIT_FAILURE, "clock_gettime() failed");

        int i;
        for (i = 0; i < concurrency; i++) {
                pid_t pid;
                pid = fork();
                if (pid == -1) {
                        warn("fork() failed\n");
                        int j;
                        for (j = 0; j < i; j++) {
                                if (kill(pids[j], SIGINT) == -1)
                                        warn("kill() failed");
                        }

                        free(pids);
                        exit(EXIT_FAILURE);
                } else if (pid == 0) {
                        pause();
                } else {
                        pids[i] = pid;
                }
        }
        if (clock_gettime(CLOCK_MONOTONIC_RAW, &end) == -1) {
                warn("clock_gettime() failed");

                int j;
                for (j = 0; j < concurrency; j++)
                        if (kill(pids[j], SIGINT) == -1)
                                warn("kill() failed");
                free(pids);
                exit(EXIT_FAILURE);
        }
        if (system("free") == -1) {
                for (i = 0; i < concurrency; i++)
                        if (kill(pids[i], SIGINT) == -1)
                                warn("kill() failed");
                free(pids);
                err(EXIT_FAILURE, "system() failed");
        }

        long diff = (end.tv_sec - begin.tv_sec) * 1000000000 + (end.tv_nsec - begin.tv_nsec);
        printf("%ld\n", diff);
        for (i = 0; i < concurrency; i++) {
                if (kill(pids[i], SIGINT) == -1)
                        warn("kill() failed");
        }
        free(pids);
}

static void *thread_fn(void *arg)
{
        pause();
        /* shouldn't reach here */
        abort();
}

static void measure_thread_creation_time(void)
{
        pthread_t *tids;
        tids = malloc(sizeof(pthread_t) * concurrency);
        if (tids == NULL)
                err(EXIT_FAILURE, "malloc() failed");

        struct timespec begin, end;
        if (clock_gettime(CLOCK_MONOTONIC_RAW, &begin) == -1)
                err(EXIT_FAILURE, "clock_gettime() failed");

        int i;
        for (i = 0; i < concurrency; i++) {
                int ret;
                ret = pthread_create(&(tids[i]), NULL, thread_fn, NULL);
                if (ret) {
                        errno = ret;
                        free(tids);
                        err(EXIT_FAILURE, "pthread_create() failed\n");
                }
        }
        if (clock_gettime(CLOCK_MONOTONIC_RAW, &end) == -1) {
                free(tids);
                err(EXIT_FAILURE, "clock_gettime() failed");
        }
        if (system("free") == -1) {
                free(tids);
                err(EXIT_FAILURE, "system() failed");
        }

        long diff = (end.tv_sec - begin.tv_sec) * 1000000000 + (end.tv_nsec - begin.tv_nsec);
        printf("%ld\n", diff);
        free(tids);
}

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

        if (argc < 3)
                show_usage();

        char op = argv[1][0];
        if (op != 'p' && op != 't') {
                fprintf(stderr, "operation should be 'p' or 't': %c\n", op);
                show_usage();
        }
        concurrency = atoi(argv[2]);
        if (concurrency <= 0) {
                fprintf(stderr, "concurrency should be >= 1: %d\n", concurrency);
                show_usage();
        }

        switch (op) {
        case 'p':
                measure_process_creation_time();
                break;
                ;;
        case 't':
                measure_thread_creation_time();
                break;
                ;;
        default:
                show_usage();
        }

        exit(EXIT_SUCCESS);
}

プロセス生成の場合

上記プログラムを用いて1000個プロセスを生成した際の1プロセスあたりの生成時間と同メモリ使用量を採取しました。1プロセス当たりのメモリ使用量を厳密に求めるのは難しいので、(1000プロセス生成時のメモリ総使用量 - プログラム実行前のシステムメモリ総使用量)/1000で代替しました。このような性能を5回測定をした際の平均値を次に示します。

名前 WSL guest Ubuntu native Ubuntu
生成時間[ms] 4.53 0.082 0.0448
メモリ使用量[MB] 2.19 0.104 0.105

guest Ubuntuとの比較においては生成時間は50倍程度の差がついています。プロセス生成時間についてはguest Ubuntuのほうが優れているといっていいでしょう。

native Ubuntuとの比較においては、生成時間は100倍以上の差がついています。どちらの場合も同じデータに繰り返しアクセスするような処理はしていないので、native Ubuntuのデータはすべてキャッシュメモリに乗っているもののWSLのそれは乗っていないためにこの性能差が出たとは考えづらいです。他にもハード的に両者のプロセッサ性能、メモリアクセス性能による性能の違いが考えられますが、それだけではこれだけ大きな差は説明つきません。プロセス生成コストについてはWSLのほうがnative Ubuntuに比べて遅いと言っていいでしょう。これだと、プロセスをたくさん生成するshell scriptが遅く感じるのも説明がつきます。

メモリ使用量についてもguest Ubuntuおよびnative Linuxと20倍程度の差がついています。この値はnativeか否か、およびハードスペックの差は関係無く、かつ、プロセス生成時には生成した子プロセスのユーザメモリはほぼ割り当てられていない(子プロセスのメモリにwriteしたときにCopy-on-Writeによって子プロセス用の新規メモリが割り当てられる)であろうことを考えると、WSLはプロセスごとに消費するカーネルメモリの量がnative Ubuntuに比べて非常に多いと考えられます。

余談ですが、私のノートPCでは2000と少々のプロセスを生成した時点でswapが始まりました。あまりやる人はいないと思いますが、WSL上でプロセスを大量に動かすような処理を動かすのはnative Ubuntuの場合に比べてかなりのハンデを背負うでしょう。

ある人から、WSLの場合はfork()時にCopy-on-Writeせずにメモリを全コピーしているのではないかと指摘されたことより、WSLにおいて次の2パターンについてデータを採取してみました。

  • プロセス生成前に256MBの追加メモリ領域を獲得する(アクセスはしない)。
  • プロセス生成前に256MBの追加メモリを獲得した上で、対応する全ページにwriteアクセス

上記2つの場合どちらについても、追加メモリ領域獲得およびそこへのアクセスにかかる時間は生成時間に含んでいません。

5回計測した平均値は次のようになりました。

名前 WSL(追加メモリ領域なし。参考値) WSL(追加メモリ領域あり。writeアクセスなし) WSL(追加メモリ領域あり。writeアクセスあり)
生成時間[ms] 4.53 4.18 23.7
メモリ使用量[MB] 2.19 1.96 2.16

メモリ使用量については結果はどれもほぼ同じでした。このため、上記の仮説に反してCopy-on-Writeのしくみは働いていると考えられます。

その一方で生成時間は追加メモリ領域ありの場合と追加メモリ領域ありでwriteアクセス無しの場合はほぼ同じ値であるのに対して、追加メモリ領域ありでwriteアクセスなしの場合はそれらより5~6倍程度遅かったです。メモリ使用量は変化していないことより、追加メモリ領域及びそこへのwriteアクセスありの場合、追加メモリ領域の量に比例した何らかのreadアクセスがfork()時に発生しているものと考えられます。

スレッド生成の場合

上記プログラムを使ってスレッド生成時の生成時間および1スレッドあたりのメモリ使用量も求めました。5回測定した平均値を次に示します。

名前 WSL guest Ubuntu native Ubuntu
生成時間[ms] 0.248 0.0167 0.0108
メモリ使用量[MB] 0.0508 0.0360 0.0366

生成時間もメモリ使用量もプロセスの場合と同じくWSLの性能が一番悪いですが、差は明らかに縮まっています。前者についてはそれぞれ50倍と100倍の差があったものが15倍と20倍程度の差になっています。後者についてはguest Ubuntu, native Ubuntuどちらについても20倍の差があったものが2倍弱ほどの差になりました。WSLの場合は他の2つに比べてプロセス生成コストに比べたスレッド生成コストの低さが顕著であると言えるでしょう。 1

I/O性能

read性能

1GBのファイルを1MBごとに1024回に分けて読み出すことによってread I/O性能を採取しました。ファイルがページキャッシュに乗っている場合と乗っていない場合について採取しました。ページキャッシュの削除には、下記ddの前にecho 3 >/proc/sys/vm/drop_cachesを実行するという方法を使いました。採取方法は次の通り。

dd if=testfile of=/dev/zero bs=1M count=1024

10回測定した際の平均値を次に示します。

名前 WSL guest Ubuntu native Ubuntu
with page cache[MB/s] 2170 6360 12100
without page cache[MB/s] --- 677 489

WSLのページキャッシュありの性能guest Ubuntuに比べると3倍程度遅いです。native Ubuntuに比べた性能は6倍程度遅いです。この二つの環境におけるハードの性能差だけでこれだけの差が出るとは思えないので、WSLはnative Ubuntuに比べて読み出し性能が劣ると言えるでしょう。大規模ソースを読むときの速度差は、このページキャッシュありの場合の性能差が大きく関係していそうです。

ページキャッシュなしの場合についてはWSLにおいてページキャッシュを削除する簡単な方法が思いつかなかったので、比較はしませんでした2。guest Ubuntuにおけるページキャッシュなしの性能がハード性能限界を超えているのは、詳細は説明しませんがこれがVMだからです。

write性能

1MBのデータを1024回、合計1GBのファイルを新規ファイルに書き込むことによってwrite I/O性能を計測しました。データはbuffered I/O, direct I/Oそれぞれについて次のように採取しました。

dd if=/dev/zero of=testfile bs=1M count=1024; rm testfile
dd if=/dev/zero of=testfile oflag=direct bs=1M count=1024; rm testfile

10回測定した際の平均値を次に示します。

名前 WSL guest Ubuntu native Ubuntu
buffered I/O[MB/s] 324 596 3420
direct I/O[MB/s] 328 423 151

buffered I/OについてはWSLはguest OSに比べると2倍弱遅いことがわかります。native Ubuntuに比べて一桁程度遅いことがわかります。ストレージのwrite性能はそれほど違わないことを考えると、ファイルシステム(lxfs) and/or その他WSLのOS関連オーバーヘッドが非常に大きいと考えられます。direct I/OについてはWSLのほうが速いです。理由は不明ですが、ストレージ性能の差から来ているのかもしれません。

さらにWSLはbuffered I/Oよりもdirect I/Oのほうが速いという意味不明な結果になっています。これについても理由は不明です。WSLはbuffered I/Oとdirect I/Oの性能がほとんど変わらないという面白いデータがとれました。write時はread時とは異なりページキャッシュを使わないのかもしれない…などという想像が膨らむのですが、実装を知らないので本当のところは不明です。

おわりに

かつて、何かの記事でWSLのCPU性能はnative Ubuntuに近いするようなことが書いていましたが、それ以外の面については、少なくとも本記事で触れたような部分についてはnative Ubuntuに比べて、性能が悪い場合があると明らかになりました。今後の改善に期待です。

今後はできれば同じマシンでデータを採取したいですし、今回測らなかったようなデータもとってみたいです。また、ストレージアクセス性能測定についての、いくつかの直感に反する結果についても原因を明らかにしたいです(誰か詳しいかたがいれば教えてほしいです)。


  1. 当然ながら同じマシン上のnative Linuxの性能を測るのがベストなのですが、諸般の事情により今回はあきらめました

  2. 起動直後に上記ddを実行すればページキャッシュ無しの場合のデータを採取できると思いますが、時間の都合で、そこまではやりませんでした。

ASLRとKASLRの概要

はじめに

本記事はlinuxに存在する1Address Space Location Randomization(ASLR)、およびKASLR(Kernel ASLR)という、やたら長い名前のセキュリティ機能を紹介するものです。メモリアドレスの概念やC言語ポインタが理解できれば読める内容だと思います。

ASLR

概要

あるシステムをクラッカーが攻撃する方法はいろいろありますが、そのうちの一つが不正な方法によってプログラムに特定の命令を実行させる、不正なデータを操作させるというものです。攻撃には、(当然ながら)攻撃に使う命令、あるいはデータのアドレスが必要になります。ASLRが無い環境においてはプログラムのコードやデータは2固定されたアドレスにロードされるので、動かしているプログラムのバイナリがどんなものかわかっていれば、攻撃者が攻撃に使うコードやデータのアドレスを知るのは簡単です。

この類の攻撃を防ぐために考案されたのがASLRです。ASLRを使うと、プログラムの実行ごとに、そのコードやデータがランダムな位置に配置されます。そのため、クラッカーが攻撃に使うコードやデータのアドレスを割り出すのが困難になります3

ASLRは難しいハードウェア機構を使っているわけでなく、コードやデータを参照するときにアドレスを"0x10000000"などと絶対的に決め打ちで指定するのではなく、"所定のベースアドレス+0x10000000"などと、相対的に指定するだけです。

ここで一点注意が必要なのですが、ALSRによってスタック領域やヒープの領域のアドレスはどんなプロセスにおいてもランダム化されるのですが、コード領域や静的に配置されたグローバル変数やstatic変数などのデータ領域についてはそうではありません。以下に例を示します。

$ sleep 10000 &
[1] 7951
$ sleep 10000 &
[2] 7952
$ cat /proc/7951/maps       # sleep(pid=7951)のメモリマップ
00400000-00407000 r-xp 00000000 00:16 207201                             /bin/sleep # コード
00606000-00607000 r--p 00006000 00:16 207201                             /bin/sleep # 読み取り専用データ
00607000-00608000 rw-p 00007000 00:16 207201                             /bin/sleep # 読み書きデータ
009aa000-009cb000 rw-p 00000000 00:00 0                                  [heap]     # ヒープ
...
7ffc60378000-7ffc60399000 rw-p 00000000 00:00 0                          [stack]    # スタック
$ cat /proc/7952/maps       # sleep(pid=7952のメモリマップ)
00400000-00407000 r-xp 00000000 00:16 207201                             /bin/sleep
00606000-00607000 r--p 00006000 00:16 207201                             /bin/sleep
00607000-00608000 rw-p 00007000 00:16 207201                             /bin/sleep
00cf1000-00d12000 rw-p 00000000 00:00 0                                  [heap]
...
7ffd1a406000-7ffd1a427000 rw-p 00000000 00:00 0                          [stack]
...

というのも、上記のようにコード領域と静的に配置されたデータ領域のアドレスを相対的に指定するにはプログラムをPosition Independent Code (PIC) としてビルドされたPosition Independent Executable(PIE)である必要があるからです。gccにおいてはコンパイル時に-fpicオプションを付けると実現できます。こうなっていれば当該バイナリから生成されたプロセスは、任意のコード、データがランダム配置されます。以下に、私の環境ではPIEとして存在するsshを2つ起動した場合の、それぞれのプロセスのアドレスマップを示します。

$ cat /proc/7981/maps
5626d3d9c000-5626d3e45000 r-xp 00000000 00:16 867533                     /usr/bin/ssh
5626d4045000-5626d4048000 r--p 000a9000 00:16 867533                     /usr/bin/ssh
5626d4048000-5626d4049000 rw-p 000ac000 00:16 867533                     /usr/bin/ssh
5626d4049000-5626d404c000 rw-p 00000000 00:00 0
5626d5505000-5626d5544000 rw-p 00000000 00:00 0                          [heap]
...
7ffe2bd73000-7ffe2bd94000 rw-p 00000000 00:00 0                          [stack]
...
$ cat /proc/7985/maps
5616589f2000-561658a9b000 r-xp 00000000 00:16 867533                     /usr/bin/ssh
561658c9b000-561658c9e000 r--p 000a9000 00:16 867533                     /usr/bin/ssh
561658c9e000-561658c9f000 rw-p 000ac000 00:16 867533                     /usr/bin/ssh
561658c9f000-561658ca2000 rw-p 00000000 00:00 0
561658d81000-561658dc0000 rw-p 00000000 00:00 0                          [heap]
...
7ffc75060000-7ffc75081000 rw-p 00000000 00:00 0                          [stack]
...

PIEは通常のバイナリに比べてアドレッシングに余計な手間がかかるため、性能面では劣ります。このため、各種distroによって程度の差はありますが、セキュリティ的に重要なsshなどのバイナリはPIE、そうでないものは非PIEとしてビルドされる傾向にありました。ただし最近では可能な限りPIEをデフォルトにする動きが活発です。例えばUbuntuでは16.10からPIEがデフォルトになりました

あるプログラムがPIEかそうでないかは、たとえばfileコマンドによって確認できます。

$ file /usr/bin/ssh
/usr/bin/ssh: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=ecf7433a7d26461fc1bc7\
a6b6a4eba868e685839, stripped                # "...share object"となってる実行ファイルはPIE
$ file /bin/bash
/bin/bash: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=04eca96c5bf3e9a300952a29ef3\218f00487d37b, stripped            # "... executable"となってる実行ファイルは非PIE

有効/無効

ASLRはデフォルトで有効です。ASLRを無効化するにはsysctlのkernel.randomize_va_spaceに0を指定します(有効なときは2です。1についてはあんまり気にしなくていいです)。この設定はシステム全体に対して効果があります。

KASLR (KASLR)

概要

KASLRはASLRの考え方をそのままカーネルに持ってきたものです。KASLRはカーネルをPIEとしてビルドして、ブートのたびに自身を毎回異なるアドレスにロードします。非常に簡素な説明で恐縮ですが、ASLRさえわかっていればKASLRの機能については大して説明することがありません。

有効/無効

KASLRはlinux 3.14で導入されました。設定ファイル(.config)のCONFIG_RANDOMIZE_BASEがyになっていれば有効です。長らくデフォルト設定(defconfigと呼ばれるもの)ではこの機能は無効でした。しかし、本記事執筆時点での最新mainline kernelの4.12からはデフォルトで有効化されました。ただし、リンク先の記事にもある通り、Ubuntuなどのdistroカーネルでは以前から有効化されている例が多かったことより、これはカーネル開発者以外にはあまり影響無い話かもしれません。

KASLRには一つ罠があって、4.7以前のカーネルではhibernationと共存できないという制限があります。CONFIG_HIBERNATION=yの環境下においては、kaslrというカーネルブートオプションを明に指定した場合のみKASLRが有効になります(そのかわりhibernationは無効になります)。それ以外の場合は無効化されます。例えばUbuntuにおいて4.4系のカーネルを使ってシステムを構築している場合はこれに該当するので、デフォルトではKASLRは無効化されているため、注意が必要です。

...
unsigned char *choose_kernel_location(struct boot_params *boot_params,                                                                                                                                                             unsigned char *input,                                                                                                                                                                        unsigned long input_size,                                                                                                                                                                    unsigned char *output,                                                                                                                                                                       unsigned long output_size)                                                                                                                             {                                                                                                                                                                                                    unsigned long choice = (unsigned long)output;                                                                                                                                                unsigned long random;                                                                                                                                                                
#ifdef CONFIG_HIBERNATION
        if (!cmdline_find_option_bool("kaslr")) {
                debug_putstr("KASLR disabled by default...\n");
                goto out;
        }
#else
        if (cmdline_find_option_bool("nokaslr")) {
                debug_putstr("KASLR disabled by cmdline...\n");
                goto out;
        }
#endif
...

CONFIG_RANDOMIZE_BASE=yなカーネルで明にKASLRを無効化したい場合はカーネルのブートオプションにnokaslrを指定します。

困ったことにKASLRはdmesgに有効/無効のログを残しません。KASLRが有効かどうかを実行時に確かめるには、次のように適当なシンボルについて、お使いのカーネルのSystem.mapファイル内のアドレスと/proc/kallsymsから読み取れる実行時のアドレスをroot権限の下で4比較する必要があります。これらが異なっていればKASLRは有効、そうでなければ無効です。以下に例を示します。

$ sudo grep "T do_page_fault" /boot/System.map-$(uname -r)
ffffffff8106b780 T do_page_fault
$ sudo grep "T do_page_fault" /proc/kallsyms
ffffffffb626b780 T do_page_fault               # 上のアドレスと異なる。つまりKASLR有効

おわりに

この記事を書いた理由は、とある調べものをしているときに、「KASLRを有効にしたカーネルを動かしたはずなのにアドレスが固定だぞ!?」と気づいたのがきっかけです。私の使ってるUbuntuのドキュメントにもしっかり書いてたのでドキュメントちゃんと見てればわかってた話ですね。情けない。


  1. linux有機能というわけではなく、他のOSにも同等機能はあります

  2. プログラムが動的にロードするライブラリはまた事情が違います

  3. 後述のKASLRも含め、困難ではありますが、不可能ではありません。クラッカーと防御側の攻防はいつでもいたちごっこなのです

  4. 通常System.mapファイルはrootでないと読めないようになっていますし、/proc/kallsymsのアドレス欄はrootでないと正しい値が出ないように細工されています