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を実行すればページキャッシュ無しの場合のデータを採取できると思いますが、時間の都合で、そこまではやりませんでした。