WSL2 を導入すると、思った通り Windows10 は仮想マシン上で動作した(と思う)

# WSL2 と Hyper-V と Parent partition

現在では多くの方が自分のPCにWSL2(Windows Subsystem for Linux 2)をインストールし、Windows上でLinuxライフを満喫しているのではないかと思います。

さてさてマイクロソフトさんからWSL2の発表があった時､それを伝えていた記事の中に「WSL2はHyper-Vの技術を利用する」という旨の文言がありました。それ以来ずーっと疑問に思っていたのが､
<font color="Red">「Hyper-Vを使うなら､ホストOSとなるWindows10は仮想マシン上で動くんだよなぁ。性能低下とか起きないのかな??」</font>
ということです。

そもそもHyper-Vというハイパバイザは"Type-I型"と称される仮想化方式を採用していて､ホストOSが仮想マシン上で動作するはず...。Hyper-V界隈の単語で表現すると､ホストOSは"Parent partition"(日本語だと[親パーティション](https://docs.microsoft.com/ja-jp/virtualization/hyper-v-on-windows/reference/hyper-v-architecture)というらしい)の上で動作することになるはずです。

これをWSL2に当てはめてみると､ホストOSのWindows10は仮想マシン上で動作することになります。[atmarkitの記事](https://www.atmarkit.co.jp/ait/articles/1906/14/news019.html)に出ているWSL2のアーキテクチャなる絵を見てみると､推測の通りにWindows10の部分(絵中ではWindowsカーネル+Win32ユーザーモードだと思われる)はHyper-Vの上で動いていて仮想化された環境のようです。

このまま白黒つけずにいると眠れなくなるかもしれないので､Windows10が仮想マシン上で動作しているのかを実機で確認してみることにしました。ちなみに､[ycombinator](https://news.ycombinator.com/item?id=23090143)でも一部のマニアがその話題で盛り上がっているようです。"parent partition"とwebページ内で検索するとヒットしました。

# どうやって仮想マシン上でのWindows10動作を確認するか？

単純にホストOSであるWindows10の性能低下を見ても何かイマイチだと思ったので､違う方法を用いて確認することにしました。実はその方法を用いても仮想化されていることを100%証明することにはならないのですが､性能低下を確認する方法よりは確度が高いと個人的には思います。

これはどういうことかと言うと、性能低下に着目した場合には
&nbsp;&nbsp;&nbsp;&nbsp;**「仮想化は導入してないんだけどね...、ちょっとドライバとかカーネルとか変えてみたら今までより悪くなったみたい。テヘペロ」**
なんて場合も容易に想像できます。性能低下を引き起こす要因は、仮想化以外にも考えたらキリがありません。なので今回は
&nbsp;&nbsp;&nbsp;&nbsp;**「仮想化環境でのみ顕在化する兆候」**
に焦点を当て、そこにWindows10の負荷状態を組み合わせてWindows10が仮想マシン上で動作していることを確認することにしました。

#### 仮想マシンのメモリアクセス方法に着目する

もしホストOSとなるWindows10が「物理マシン上で直接動作している場合」or「仮想マシン上で動作している場合」とではメモリアクセスの方法が異なります。詳しい内容はこちらの[図入り解説](https://thinkit.co.jp/article/125/2)などを参照していただくとして､以下のような違いがあります。どちらもやりたいことは
<font color="Red">「OS上のプロセスの仮想メモリアドレス を 物理マシンの物理メモリアドレス に変換する」</font>
なのですが､仮想マシンがあると一手間かかってしまいます。

**物理マシン上で直接動作:**
(1) OS上のプロセスの仮想メモリアドレス を 物理マシンの物理メモリアドレス に変換
**仮想マシン上で動作:**
(1) OS上のプロセスの仮想メモリアドレス を 仮想マシンの物理メモリアドレス にまず変換
(2) 変換された仮想マシンの物理メモリアドレス を 物理マシンの物理メモリアドレス に変換

OSが仮想マシン上で動作している場合、x86_64 CPUでは(2)の過程においてCPUハードウェアによるアクセラレーション機構をかなり前から利用しています。Intel社製のCPUであればEPT(Extended Page Table)､AMD社製のCPUであればNPT(Nested Page Table)と呼ばれているものです。

上記のアドレス変換はどれも原理的にメモリ上のページテーブルを走査して実施されるため､"Page table walk"とも言われます。ただ､この走査処理はコストが大きいのが問題です。なので1回目のアクセスでのアドレス変換結果をTLB(Translation Lookaside Buffer)にキャッシュさせておき、2回目以降のアクセスではそのキャッシュされたアドレス変換結果を利用することで同じアドレス変換をサボろうとします。

とはいえTLBのサイズは固定容量なので、アドレス変換の範囲が広くなってくるとキャッシュされているアドレス変換結果を利用できず走査処理が発生します。計算機界隈では「TLBミス」というやつです。このとき、CPUはアドレス変換を待っているのでストールしてしまいます。

#### イベントカウンタを使って上手く数値に落とし込んでしまおう！

さてさて幸運なことにIntel社製のCPUは、各種イベントの発生回数を計測することが出来るハードウェアカウンタを持っています。さらには
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; _上記(2)のアドレス変換過程に費やされたCPUサイクル数_
という､多くの仮想マシンマニアがヒャッハーするイベントを計測することが出来ます。Intel社製CPU信者のバイブルであるIA32のマニュアルによると､例えばワタシのノートPCに載っかっている Core i3-6006U というCPUでは _EPT.WALK_PENDING_ というイベントが相当します。

もし仮に「WSL2を導入してもホストOSのWindows10が仮想マシン上で動いていない」のであれば、ホストOSついては(2)のアドレス変換処理自体が存在しません。つまり、Windows上で大量のTLBミスを起こさせるようなプログラムを動かしても _EPT.WALK_PENDING_ のイベントには全く影響しません。

逆に「ホストOSのWindows10が仮想マシン上で動いている」のであれば、_EPT.WALK_PENDING_ のイベントカウンタが反応するでしょう。(仮想マシン上でLinuxが動いているかもしれないので、Windowsとは関係ないところでイベントカウンタが反応するかもしれませんが)

# 確認作業の概要

上記の安易な目論見をベースに、下記の内容を実施しました。

1. まずはホストOSがWindows10であるノートPCにWSL2を入れて､WSL2モードでUbuntu 20.04 LTSが動く状態にさせておく。

2. Windbgをインストールし､イベント計測のカウンタを読み出すために必要なRDMSR/WRMSR命令を使える状態にする。MSR(Model Specific Register)はCPUに実装されているレジスタの一種で、ワタシのノートPCの場合では8つ(_IA32_PMC0-7_, offset 0xC1 - 0xC8)のどれかをRDMSRするとイベントカウンタ値を取得可能。

3. Windows10上でメモリ負荷をかけない状態でイベントカウンタの増分を記録する。

4. Windows10上でマイクロソフト社謹製のメモリ負荷ツール([testlimit](https://docs.microsoft.com/ja-jp/sysinternals/downloads/testlimit))を実行しつつ、イベントカウンタの増分を記録する。

5. 上記3.と4.の結果を比較する。4.の場合は3.の場合よりもイベントカウンタの増分が大きいことを期待。

# 実際の作業

ノートPCにセコセコとWSL2とWindbgをインストールし、メモリ負荷ツールもダウンロードしておきます。

Windbgの中で利用するのは `kd.exe` というコマンドです。このコマンドはカーネルデバッガで、コンソールから `RDMSR/WRMSR` コマンドを利用できるようになります。1つだけ注意が必要で、マルチコア環境では実行するCPUコアを固定して `kd.exe` をキックしないと計測が上手くいかない可能性があります(理由は後ほど)。CPUコアを固定するためには、下記のようにコマンドプロンプトから `start` コマンドを経由させて `kd.exe` を実行します。 

```:cmd.exe
> start /Affinity 1 kd.exe -kl
```

`/Affinity` オプションは、後ろの数字と組み合わせてプロセスを実行させるCPUコアを固定させます。後ろの引数 `1` と組み合わせると、CPUコア#0でプロセスが実行されます。`-kl` オプションは、ローカルのカーネルに接続するための `kd.exe` のおまじないです。
(オプションの解説は[こちら](https://docs.microsoft.com/en-us/archive/blogs/santhoshonline/how-to-launch-a-process-with-cpu-affinity-set))

これによりカーネルデバッガ(`kd.exe`)が動きました。ヨシヨシ。

```:kd.exeコンソール
Microsoft (R) Windows Debugger Version 10.0.19041.1 AMD64
Copyright (c) Microsoft Corporation. All rights reserved.

Connected to Windows 10 19041 x64 target at (Fri Jul 10 16:49:17.143 2020 (UTC + 9:00)), ptr64 TRUE
Symbol search path is: srv*
Executable search path is:
Windows 10 Kernel Version 19041 MP (4 procs) Free x64
Product: WinNt, suite: TerminalServer SingleUserTS
Built by: 19041.1.amd64fre.vb_release.191206-1406
Machine Name:
Kernel base = 0xfffff807`16400000 PsLoadedModuleList = 0xfffff807`1702a2b0
Debug session time: Fri Jul 10 16:49:17.498 2020 (UTC + 9:00)
System Uptime: 0 days 23:46:53.486
lkd>
```

早速 `RDMSR` を実行して、_IA32_PMC0_(offset 0xC1)のカウンタ値を見てみます。0x0で止まっています。

```:kd.exeコンソール
# Read the counter value
lkd> rdmsr 0xc1
msr[c1] = 00000000`00000000
```

仮にWindows10が仮想マシン上で動いているとなると、Hyper-Vの実装によってはMSR自体が仮想化されていて「RDMSRによるread時は常に0x0を返す」なんてことも出来てしまいます。なので予め_IA32_PMC0_がカウントが可能であることを確認しておきます。ここでは_CPU_CLK_UNHALTED.THREAD_P_というイベントを計測してみます。このイベントを設定すると、CPUコアがidleでなければ1サイクル毎に1カウントアップします。

イベントの指定とカウント開始/停止の合図は、offset 0x186 のMSR(_IA32_PERFEVTSEL0_)で指定します。カーネル/ユーザ空間ともにカウント有り、_CPU_CLK_UNHALTED.THREAD_P_ のイベント情報(Event number 0x3C、Umask 0x00)を設定してカウントを開始します。

```:kd.exeコンソール
# Start counting
  # Stored value 0x43003c
  # bit[22]  : enable/disable counting
  # bit[17]  : counting during Ring0 (OS)
  # bit[16]  : counting during other than Ring0 (User)
  # bit[8-15]: Umask
  # bit[0-7] : Event number
lkd> wrmsr 0x186 0x43003c
```

5秒くらいで計測をストップして、カウンタ値を取得します。

```:kd.exeコンソール
# Stop counting
  # bit[16,17,22] -> cleared
wrmsr 0x186 0x00003c

# Read the counter value
lkd> rdmsr 0xc1
msr[c1] = 00000000`079d64b3
```

0x079d64b3 という値が返ってきたので、ちゃんとカウントされたようです。大丈夫だろうきっと。

さて、今度は本命の _EPT.WALK_PENDING_ を計測します。まずはメモリ負荷なしの場合。
カーネル/ユーザ空間ともにカウント有り、_EPT.WALK_PENDING_ のイベント情報(Event number 0x4F、Umask 0x10)を設定してカウントを開始します。最初に _IA32_PMC0_(offset 0xC1) をゼロクリアしておきます。

```:kd.exeコンソール
# Counter clear
lkd> wrmsr 0xc1 0x0

# Read the counter value
lkd> rdmsr 0xc1
msr[c1] = 00000000`00000000
```

同じように5秒間くらい計測します。

```:kd.exeコンソール
# Start counting
lkd> wrmsr 0x186 0x43104f

(Wait 5 seconds ...)

# Stop counting
lkd> wrmsr 0x186 0x00104f

# Read the counter value
lkd> rdmsr 0xc1
msr[c1] = 00000000`01379f03
```

おお、計測値が 0x0 ではありません!! 
これはHyper-Vで仮想マシンを実現するためにEPTを使っているということを示しています。
とはいえWSL2のUbuntu VMかもしれないので、Windows10上でメモリ負荷プログラムを動かした場合の _EPT.WALK_PENDING_ を計測します。

まずは _IA32_PMC0_(offset 0xC1) のゼロクリアを忘れずに。

```:kd.exeコンソール
# Counter clear
lkd> wrmsr 0xc1 0x0

# Read the counter value
lkd> rdmsr 0xc1
msr[c1] = 00000000`00000000
```

同じように5秒間くらい計測します。

```:kd.exeコンソール
# Start counting
lkd> wrmsr 0x186 0x43104f

(Wait 5 seconds ... while executing testlimit64.exe)

# Stop counting
lkd> wrmsr 0x186 0x00104f

# Read the counter value
lkd> rdmsr 0xc1
msr[c1] = 00000000`128b7b5f
```

5秒間待っている間に、コマンドプロンプトからメモリ負荷プログラム(testlimit)を起動させておかなければなりません。こちらも `start` コマンドの `/Affinity` オプションを使って `kd.exe` と同様にCPUコア#0で実行されるようにしておきます。イベントカウンタはCPUコア毎に独立しているので、これをやらないとtestlimitのプロセスが別のCPUコアで実行されてしまって計測自体が無意味になる恐れがあります。

```:cmd.exe
> start /Affinity 1 testlimit64.exe -d -c 8192
```

さてさて、結果はというと...(既にチラ見状態ではありますが)

```:EPT.WALK_PENDINGイベントカウンタの増分
(windows上でメモリ負荷無し)
lkd> rdmsr 0xc1
msr[c1] = 00000000`01379f03

(windows上でメモリ負荷有り)
lkd> rdmsr 0xc1
msr[c1] = 00000000`128b7b5f
```

当初の想定通り、_EPT.WALK_PENDING_ のカウンタ値がかなり増えて15倍くらいになりました。
これはもうホストOSのWindows10が仮想マシン上で動いていると言っても過言ではないでしょう。ヤッタネ!!

# 仮想化による影響

ホストOSのWindows10が仮想マシン上で動作しているとなると、どんな影響があるのでしょう!?

#### システム性能の低下

上の方で「メモリアクセスの仕方が異なる」と書きましたが、仮想化された環境下ではメモリアクセス遅延が増えることになります。

さらにはI/OアクセスなどのCPUの特権命令実行にはハイパバイザによるトラップ処理が必要となることが多く、その場合には「Windows10 - ハイパバイザ間」のコンテキストスイッチという余計な時間も増えてしまいます(Intel社製CPUの用語だと、VMExit/VMEntryのところです)。MMIOへのアクセスもまだトラップ無し技術がインプリされていないと思うので、PCIeデバイスへのアクセスも同じ問題に陥りやすいでしょう。

一般的に性能観点で重要なのは、
&nbsp;&nbsp;「ハイパバイザによるトラップ処理を必要とするイベント発生の頻度やTLBミス回数」
&nbsp;&nbsp;「イベント発生後のハイパバイザやハードによる処理時間単価」
ではないかと思います。これらの値が大きくなると、性能低下がより顕著に見えてきてしまいます。

#### システム機能の制限

他にありそうな影響は、「ハードウェア機能が使えない」です。既に顕在化した例が1つあって、WSL2が出た当初に Virtualbox や VMWare などの仮想化ソフトウェアをWindows10で利用できませんでした。

最近の仮想化ソフトウェアはCPUハードの仮想化支援機構を利用します。しかしながら、WSL2導入によりHyper-Vの仮想マシン上でWindows10が動作することになり、この仮想マシン内のCPUで仮想化支援機構が有効化されていなかったために起きたのではないかと推測しています。この手のお話はいわゆる"Nested virtualization"と呼ばれるもので、興味のある方は[本家マイクロソフトさんの解説](https://docs.microsoft.com/ja-jp/virtualization/hyper-v-on-windows/user-guide/nested-virtualization)の記事を眺めてみると良いかと思います。

# おわりに

きっと未来にリリースされるであろうWSL3ではまたソフトウェアアーキテクチャを変えてくるのでしょうか!?
とはいえこれにて気になっていた事が1つ減ったので、今後はグッスリ眠れそうです。