2022年3月25日金曜日

Linux KVM + virtio-net のレイテンシーを削減するヘンな方法

Linux KVM の virtio-net を利用している際にリモートホストへの PING 結果がマイクロ秒のオーダーで安定しない(ブレる)という話を見かけました。

Linux KVM の仕組みや QEMU のデバイスエミュレーションの仕組みを考えれば「そんなもの」かなと思い、調査および追加の実験をしてみました。なお、本記事は x86 アーキテクチャの Linux KVM の動作に基づきます。


パケットが送信される仕組み

ping コマンドなどにより発生した、送信パケットは OS のネットワークスタック(L3, L2)を通じてイーサネットのドライバに引き渡されます。
今回はLinux KVMのデバイスエミュレーションを利用した際のレイテンシーに注目したいため、プロセスからデバイスドライバまでデータ届く流れについては触れません。 Linux KVM の仮想マシンで使われる virtio-net に注目します。
仮想的なネットワークインターフェイスである virtio-net 、またそのベースとなる、ホスト−ゲスト間の通信に使われる virtio-pci では、ゲストOSのメモリ空間にある送信対象データをバッファへのポインタをリングバッファに積み、I/Oポートを叩くことで VMM (Linux KVM) 側に通知します。
このあたりの仕組み(PCIデバイスに模倣する virtio のゲスト・ホスト間通信の仕組み)は過去にエンジニアなら知っておきたい仮想マシンのしくみ スライドにて解説しているので、そちらもご確認ください。

https://github.com/torvalds/linux/blob/4f50ef152ec652cf1f1d3031019828b170406ebf/drivers/net/virtio_net.c#L1762 に、ホストへ通知すべき送信データが準備できた際ホスト側の virtio-net に通知するブロックがあります。
        if (kick || netif_xmit_stopped(txq)) {
            if (virtqueue_kick_prepare(sq->vq) && virtqueue_notify(sq->vq)) {
			u64_stats_update_begin(&sq->stats.syncp);
			sq->stats.kicks++;
			u64_stats_update_end(&sq->stats.syncp);
		}
	}

virtqueue_notify(sq->vq) は仮想デバイスの I/O ポートへのアクセスする実装となっています。そのセンシティブ命令を契機としてゲストから VMM に制御が移ります。デバイスモデル(qemu-kvm)のI/Oポートハンドラがゲストからの割り込み理由を判断し、 virtio-net のゲスト側リングバッファにあるデータをバックエンドのネットワークインターフェイスに渡します。
この際(Intel 表記で) VMX non-root → VMX root の kvm → qemu-kvm (ring3) と遷移し、場合によっては qemu-kvm がネットワークスタックにパケットを渡す前にプリエンプションが発生する可能性があり、送信タイミングが遅れる原因になり得ます。

 パケットが受信される仕組み


では、パケットが届いた場合はどうなるでしょうか? qemu-kvm がゲスト行きの受信データを受け取ると、予め用意されているゲスト側のメモリ領域に受信データを書き込み「ゲストに対して割り込みをかけます」。
本物のx86のプロセッサであれば、IRQ(Interrupt ReQuest)を受け付けるための信号線があり、それを介して割り込みを通知する方法と、割り込み通知用のアドレス空間に対するメモリ書き込みトランザクションにより割り込みを通知するMSI(MSI-X)割り込みがあります。仮想マシンの場合、仮想プロセッサにはIRQ線、MSI通知用のアドレス空間にメモリトランザクションを行ってもそれを受けてくれる相手がいないので、仮想マシンのプロセッサを「割り込みがかかった状態」にすることで割り込む挿入を実現することになります。

https://github.com/torvalds/linux/blob/0564eeb71bbb0e1a566fb701f90155bef9e7a224/arch/x86/kvm/vmx/vmx.c#L4574 にある vmx_inject_irq() に、 Intel VT でゲストに割り込みを通知する実装があります。
static void vmx_inject_irq(struct kvm_vcpu *vcpu)
{
	struct vcpu_vmx *vmx = to_vmx(vcpu);
	uint32_t intr;
	int irq = vcpu->arch.interrupt.nr;

	trace_kvm_inj_virq(irq);

	++vcpu->stat.irq_injections;
	if (vmx->rmode.vm86_active) {
		int inc_eip = 0;
		if (vcpu->arch.interrupt.soft)
			inc_eip = vcpu->arch.event_exit_inst_len;
		kvm_inject_realmode_interrupt(vcpu, irq, inc_eip);
		return;
	}
	intr = irq | INTR_INFO_VALID_MASK;
	if (vcpu->arch.interrupt.soft) {
		intr |= INTR_TYPE_SOFT_INTR;
		vmcs_write32(VM_ENTRY_INSTRUCTION_LEN,
			     vmx->vcpu.arch.event_exit_inst_len);
	} else
		intr |= INTR_TYPE_EXT_INTR;
	vmcs_write32(VM_ENTRY_INTR_INFO_FIELD, intr);

	vmx_clear_hlt(vcpu);
}
ここでのキモは intr |= INTR_TYPE_EXT_INTR もしくは INTR_TYPE_SOFT_INTR です。 Intel VT では、仮想プロセッサの状態を管理するVMCS構造体に対して特定のビットを立てておくと、「次にその仮想マシンを実行するときに、ゲスト側で割り込みハンドラに処理が移される」仕組みです。ただし、ここでは実際に仮想マシンの実行し、割り込みハンドラに処理を移してはいないことに注意が必要です。

では、具体的にいつ、仮想マシンの割り込みハンドラが実行されるのでしょうか?答えは、Linux KVM + qemu-kvm の場合 qemu-kvm のメインループでCPUスレッドが改めて VMX non-root モードに移行するように要求したときです。

「次に仮想CPUを実行するときは割り込みハンドラを実行する必要があるよ」設定されても、その次にスケジュールされるタイミングは qemu-kvm の仮想CPU実行スケジュール、Linuxのスケジューラに依存することになります。仮想マシンのCPU負荷が低い場合には、qemu-kvmはVMX non-rootへの遷移を抑制して他のプロセスへのプリエンプションを許すことにより負荷を下げます。しかし、パケットが届いた後にqemu-kvmのCPUスレッドにプリエンプションが発生し、VMX non-rootへ遷移するまでがネットワークレイテンシーの増加として見えることになります。


VMX non-root でのビジーループは解決策になるか

では、ゲストがビジー状態でCPUを使い続ければ他のプロセスへのプリエンプションが発生せず、レイテンシーに改善が見られるのではないか?という仮説がありました。この方法で、ゲストOSがHLT命令などを発効してCPUを手放すことが減るというメリットは確かにありそうですが、2つの考慮点があります。
  • 仮想プロセッサが割り込みハンドラに制御を移すのはVMX rootからVMX non-rootに制御を移すタイミングです。このため、VMX non-rootでビジーループを回っていても割り込みハンドラに制御が移るわけではありません。
  • VMX non-rootでビジーループを回り続けた果てにVMX rootに遷移したとき、Linux カーネルから見れば、そのCPUスレッドは「大量のCPU時間を消費した」プロセスと認識します。このため、負荷が高いシステムでは、Linuxカーネルは他のプロセスに積極的にプリエンプションしようとするでしょう。これでは、次回のVMX non-rootへの遷移が遅れてしまいます。


ちょっとしたカーネルモジュールでレイテンシー性能を改善する

ここでは、ちょっとしたカーネルモジュールをロードして、レイテンシー改善が可能かを試してみました。コードは https://github.com/hasegaw/cheetah/blob/master/cheetah.c です。以下の関数をカーネルスレッドとしてビジーループで回します。

static void kthread_main(void)
{
        u32 vmx_msr_low, vmx_msr_high;

        rdmsr(MSR_IA32_UCODE_REV, vmx_msr_low, vmx_msr_high);

        schedule();
}

rdmsr は、 x86 アーキテクチャにある Machine-Specific Register を読み取ります。この命令はデバイスモデルでのエミュレーションが必要になるセンシティブ命令であるため、強制的に VMEXIT が発生して VMX non-root → VMX root への遷移が発生し、qemu-kvmでRDMSRのエミュレーションが行われ、その結果をもって再度VMENTERされます。
RDMSRのエミュレーションは未改造のqemu-kvmにおいて比較的コストの低いエミュレーション処理で、特に副作用が想定されないため、これを選んでいます。HLTなどを利用するとVMMがすぐにゲストに処理を戻さないかもしれませんが、RDMSRであれば、他プロセスへのプリエンプションがない限りは、すぐに再度VMENTERによりVMX non-rootに移行することが想定され、このタイミングで割り込みハンドラへの制御が移るわけです。


測定結果

Linux KVMで動作するシステム上のLinuxゲスト間でpingコマンドを実行した際のレイテンシーを測定してみました。下記が90%パーセンタイル値です。
  • RDMSRのビジーループなし(通常の状態) —— 614us (100.0%)
  • RDMSRのビジーループなし、ゲスト内で perl -e 'while(1) {}' でビジーループ —— 516us (84.0%)
  • RDMSRのビジーループあり —— 482us (78.5%)
ごく一般的なLinuxゲストと比較すると、先のRDMSRのビジーループを回したLinuxゲストでは20%程度のレイテンシー低下が確認できました。

このデータは2021年3月(本記事の執筆時点からちょうど1年前)に割とノリで試したもので、であまり細かいデータを取っていないため、上記の値しか残っていないことにはご容赦ください。


割り込み通知からポーリングループへの移行

ゲストがビジー状態で回り続けていても通知を受けられないのなら、ひとつの選択肢としてはポーリングループでホストからの通知が受けられたらいいのに、という発想になるかと思います。実際、DPDKではプロセスがループでポーリングし続けることで大幅にレイテンシーを短縮しているわけです。ホストOSからゲストOSへの通知をポーリングループで監視すればよいのではないでしょうか?

今回は、virtio-netに対して手を入れるような事はしなかったのですが、ホストOSからの割り込み通知を待たずにリングバッファへ新着データを拾いにいくことは可能な気がします。しかし、本当にレイテンシー面を気にするのでれば SRIOV などを検討するほうがいいでしょう。

Linux KVM ではありませんが、同じく Intel VT ベースで仮想マシン技術を提供する VMware 社のホワイトペーパー Best Practices for Performance Tuning of Latency-Sensitive Workloads in vSphere VMs には、下記の記述があります。



まとめ

Linux KVM の virtio-net を利用している際にリモートホストへの PING 結果がマイクロ秒のオーダーで安定しない(ブレる)理由について、Linux KVM、qemu-kvmとゲスト側カーネルの間で何がおきるかを考えてみました。
また、ゲストへの割り込み挿入タイミングを増やすために、ゲストカーネル上で数行で書けるカーネルスレッドを動かすことにとって、実際にレイテンシー性能がいくらか向上することを確認しました。
マイクロ秒オーダーの時間のブレを気にする場合は、最終的にはSRIOVやポーリングモードの導入、そもそも(VMENTER/VMEXITだって遅いので)VMM上ではなく物理的なマシンの利用が解決策になると思いますが、仮想マシンのしくみがこんな物なんだと理解しておくのは悪くない事かと思います。




0 件のコメント:

コメントを投稿