ARM LinuxはTTBR1を使っているのか

LWNのSupporting KVM on the ARM architectureの記事にHYPモードのページテーブルに関して、以下のような説明がありました。

for the SVC and USR modes, the hardware has two separate page table base registers, which are used to provide the familiar address space split between user space and kernel. HYP mode only uses a single page table base register and therefore does not allow the address space split between user mode and kernel.

これを読むと、ARMアーキテクチャではLinuxはユーザスペースとカーネルスペースで別々のページテーブルを使っているので1つしかページテーブルを持たないHYPモードでは代わりができない、という風に読めました。私の把握してる範囲ではLinuxはページテーブルを1つしか使ってなかったように記憶していたので確認してみました。

結論から言うと、LPAEが有効になっていない場合はページテーブルは1つだけ使い、LPAEが有効になっている場合はターゲットボードやカーネルコンフィグによってはページテーブルを2つ使う場合があるようです。

以下、例によってリファレンスマニュアルとソースコードを読んで確認してみます。

ARMv7には、OSがカーネルとユーザ用にページテーブルを分けて持つことができるように、ページテーブルのアドレスを格納するレジスタ(x86のCR3相当)が2つ用意されています。これがTTBR0(ユーザ用)とTTBR1(カーネル用)です。またCP15にTranslation Table Base Control Register (TTBCR)というレジスタがあり*1、これを設定することでTTBR0とTTBR1の使い方を制御します。LPAEがなかった頃は、TTBCRの下位3ビット(TTBCR.N[2:0])でTTBR1をどのように使うかを指定することができ、Nを0に設定するとアドレス変換の際にTTBR1を使わずTTBR0のみを使うようになります。

TTBCRはv7_ttb_setupというアセンブリマクロで設定されるのですが、ARM(32bit)にはarch/arm/mm/proc-v7-2level.Sarch/arm/mm/proc-v7-3level.Sの2箇所にその定義があります。

proc-v7-2level.Sとproc-v7-3level.Sのどちらが使われるかは、arch/arm/mm/proc-v7.Sにあるifdefで決定されます。

#ifdef CONFIG_ARM_LPAE
#include "proc-v7-3level.S"
#else   
#include "proc-v7-2level.S"
#endif

LPAEが有効のときにproc-v7-3level.Sそうでないときproc-v7-2level.Sがincludeされます。

proc-v7-2level.Sのv7_ttb_setupは以下のようになっています。

        /*
         * Macro for setting up the TTBRx and TTBCR registers.
         * - \ttb0 and \ttb1 updated with the corresponding flags.
         */
        .macro  v7_ttb_setup, zero, ttbr0, ttbr1, tmp
        mcr     p15, 0, \zero, c2, c0, 2        @ TTB control register
        ALT_SMP(orr     \ttbr0, \ttbr0, #TTB_FLAGS_SMP)
        ALT_UP(orr      \ttbr0, \ttbr0, #TTB_FLAGS_UP)
        ALT_SMP(orr     \ttbr1, \ttbr1, #TTB_FLAGS_SMP)
        ALT_UP(orr      \ttbr1, \ttbr1, #TTB_FLAGS_UP)
        mcr     p15, 0, \ttbr1, c2, c0, 1       @ load TTB1
        .endm

ARM命令を知らないとわかりにくいかもしれませんが、1つ目のmcr命令でCP15にあるTTBCRにゼロ(\zero)を設定しています。つまり、TTBCR.Nは常に0に設定され、LPAEがoffのときはTTBR1は使われないことがわかります。

LPAEがonのときにどうなるかは、長くなりそうなので別エントリで解説する予定です。

*1:B4.1.153 TTBCR, Translation Table Base Control Register, VMSA

KVM/ARMにおけるMMIOハンドリング

XenがHYPモードで動いているか知りたくてHardware accelerated Virtualization in the ARM Cortex〓 Processors [pdf]を読んでいると、スライドの中に

  • “Syndrome” information on aborts available for some loads/stores
  • Syndrome unpacks key information about the instruction
    • Source/Destination register, Size of data transfer, Size of the instruction, SignExtension etc

という記述がありました。どうやらARMの仮想化支援機能にはゲストMMIOのエミュレーションを支援する(当該命令をデコードしてくれる)機能があるようです。

ご存知の通りIntel VT-xやAMD-Vにはこのデコードしてくれる機能がありません*1。そのため、MMIOによるVM exitが起きた際に、ゲストのEIPが指す命令をVMMがデコードしてやらないといけません*2。ARMではこのデコードをハードウェアがやってくれるようです。(エミュレーション自体はソフトウェアがやる必要があります。) 確かにRISCの命令セットならば、ハードウェアでデコードするのも難しくはなさそうです。

いつものリファレンスマニュアルを参照してみると、HYPモードへトラップした例外原因がCP15のHSR(Hyp Syndrome Register)に書き込まれているようです*3。HSR.ECに例外原因が書き込まれ、HSR.ISSに個々の原因毎の詳細情報がエンコードされているようです。MMIOの場合はData Abortが例外要因となります。

Data AbortのときのHSR.ISSエンコーディングを少し書きだしてみると

  • ISS[23:22]: アクセス幅(1バイト or 2バイト or 4バイト)
  • ISS[21]: 符号拡張が必要か否か
  • ISS[19:16]: レジスタ番号(ロード命令ならデータ書き込み先、ストア命令ならデータ読み込み元)
  • ISS[6]: 読み書きのどちらか?

といった感じです。

ではKVM/ARMのコードを見ていきましょう。

まずゲストでData Abortが起きてHYPモードにトラップされたときに、どのハンドラが呼ばれることになるかですが、arm_exit_handlersに例外要因毎にハンドラが用意されています。

static exit_handle_fn arm_exit_handlers[] = {
        [HSR_EC_WFI]            = kvm_handle_wfi,
        [HSR_EC_CP15_32]        = kvm_handle_cp15_32,
        [HSR_EC_CP15_64]        = kvm_handle_cp15_64,
        [HSR_EC_CP14_MR]        = kvm_handle_cp14_access,
        [HSR_EC_CP14_LS]        = kvm_handle_cp14_load_store,
        [HSR_EC_CP14_64]        = kvm_handle_cp14_access,
        [HSR_EC_CP_0_13]        = kvm_handle_cp_0_13_access,
        [HSR_EC_CP10_ID]        = kvm_handle_cp10_id,
        [HSR_EC_SVC_HYP]        = handle_svc_hyp,
        [HSR_EC_HVC]            = handle_hvc,
        [HSR_EC_SMC]            = handle_smc,
        [HSR_EC_IABT]           = kvm_handle_guest_abort,
        [HSR_EC_IABT_HYP]       = handle_pabt_hyp,
        [HSR_EC_DABT]           = kvm_handle_guest_abort,
        [HSR_EC_DABT_HYP]       = handle_dabt_hyp,
};      

Data Abortのときは kvm_handle_guest_abort関数が呼ばれます。同関数内では、MMIOは最終的に以下の箇所にたどり着きio_mem_abort関数が呼ばれます。

                /*
                 * The IPA is reported as [MAX:12], so we need to
                 * complement it with the bottom 12 bits from the
                 * faulting VA. This is always 12 bits, irrespective
                 * of the page size.
                 */
                fault_ipa |= kvm_vcpu_get_hfar(vcpu) & ((1 << 12) - 1);
                ret = io_mem_abort(vcpu, run, fault_ipa);
                goto out_unlock;

io_mem_abortでは、decode_hsr関数でHSRを読んでMMIOの処理内容がデコードされ、VGIC(仮想割り込みコントローラ)に対するMMIOの場合はvgic_handle_mmioで処理され、そうでなければkvm_prepare_mmio関数でqemuMMIOの内容を渡す準備をします。

decode_hsrは以下のようなkvm_vcpu_dabt_*関数を使ってHSRをデコードするだけで、命令解析はしていません。

static inline bool kvm_vcpu_dabt_iswrite(struct kvm_vcpu *vcpu)
{
        return kvm_vcpu_get_hsr(vcpu) & HSR_WNR;
}

static inline bool kvm_vcpu_dabt_issext(struct kvm_vcpu *vcpu)
{
        return kvm_vcpu_get_hsr(vcpu) & HSR_SSE;
}

static inline int kvm_vcpu_dabt_get_rd(struct kvm_vcpu *vcpu)
{
        return (kvm_vcpu_get_hsr(vcpu) & HSR_SRT_MASK) >> HSR_SRT_SHIFT;
}

static inline bool kvm_vcpu_dabt_isextabt(struct kvm_vcpu *vcpu)
{
        return kvm_vcpu_get_hsr(vcpu) & HSR_DABT_EA;
}

static inline bool kvm_vcpu_dabt_iss1tw(struct kvm_vcpu *vcpu)
{
        return kvm_vcpu_get_hsr(vcpu) & HSR_DABT_S1PTW;
}

/* Get Access Size from a data abort */
static inline int kvm_vcpu_dabt_get_as(struct kvm_vcpu *vcpu)
{
        switch ((kvm_vcpu_get_hsr(vcpu) >> 22) & 0x3) {
        case 0:
                return 1;
        case 1:
                return 2;
        case 2:
                return 4;
        default:
                kvm_err("Hardware is weird: SAS 0b11 is reserved\n");
                return -EFAULT;
        }
}

/* This one is not specific to Data Abort */
static inline bool kvm_vcpu_trap_il_is32bit(struct kvm_vcpu *vcpu)
{
        return kvm_vcpu_get_hsr(vcpu) & HSR_IL;
}

というわけで、ARM/KVMの場合は仮想化拡張機能のおかげでゲストMMIOのハンドリングはx86(VT-x/AMD-V)と比べて簡単なことがわかりました。

VGICに関してはまた機会があれば。。。

*1:使用頻度が高いLAPICには存在するようです

*2:arch/x86/kvm/emulate.cにエミュレーションコードがあります。

*3:B3.13.6 Use of the HSR

KVM/ARMにおける割り込み制御

LWNのSupporting KVM on the ARM architectureという記事の中で、ゲスト実行中にハードウェア割り込みが起きた時は、一度HYPモードにトラップした後、SVCモードへ移行、割り込み禁止を解除することでもう一度SVCモードで割り込みを受け取る、という記述があったので調べてみました。

KVMな人にはお馴染みのkvm_arch_vcpu_ioctl_run関数の中の、ゲストから復帰した直後に以下のようなコメントがあります。

		/**************************************************************
		 * Enter the guest
		 */
		trace_kvm_entry(*vcpu_pc(vcpu));
		kvm_guest_enter();
		vcpu->mode = IN_GUEST_MODE;

		ret = kvm_call_hyp(__kvm_vcpu_run, vcpu);

		vcpu->mode = OUTSIDE_GUEST_MODE;
		vcpu->arch.last_pcpu = smp_processor_id();
		kvm_guest_exit();
		trace_kvm_exit(*vcpu_pc(vcpu));
		/*
		 * We may have taken a host interrupt in HYP mode (ie
		 * while executing the guest). This interrupt is still
		 * pending, as we haven't serviced it yet!
		 *
		 * We're now back in SVC mode, with interrupts
		 * disabled.  Enabling the interrupts now will have
		 * the effect of taking the interrupt again, in SVC
		 * mode this time.
		 */
		local_irq_enable();

		/*
		 * Back from guest
		 *************************************************************/

確かに、ゲストで受け取った割り込みを再度ホストで発生させていることがわかります。

...と、これだけでは面白くないので、KVM/ARMにおける割り込み制御について調べてみました。(注意:説明を簡単にするためにセキュリティ拡張については省略しています。実際はもっと複雑です。)

上記の動作で不明なのは、ゲストでは割り込みが起きるとHYPモードにトラップするのに、ホスト(SVCモード)では普通にホストの割り込みハンドラ(IRQ/FIQモード)が起動する点です。

ARMv7のリファレンスマニュアルを読んでみると*1、CP15のHCRレジスタでHYPモードにトラップするか否かを制御できると書いてあります。HCR.IMOビットが1ならハードウェア割り込み時にHYPモードにトラップするといった具合です。ゲストに入る前にこのビットを設定しているのだと思われます。

実際にLinuxの当該コードを見てみましょう。arch/arm/kvm/interrupts_head.Sにconfigure_hyp_roleというマクロがあります。このマクロはworld switchする前後で呼ばれます(この辺りを参照ください)。ここでHCR.IMOを含むHYPモードへトラップする条件をいろいろ設定しています。

/* Enable/Disable: stage-2 trans., trap interrupts, trap wfi, trap smc */
.macro configure_hyp_role operation
        mrc     p15, 4, r2, c1, c1, 0   @ HCR  /* HCRレジスタの内容を読み込む */
        bic     r2, r2, #HCR_VIRT_EXCP_MASK  /* 仮想割り込み用ビットをクリアする */
        ldr     r3, =HCR_GUEST_MASK  /* マスクを読み込む(マスクについては下記参照) */
        .if \operation == vmentry
        orr     r2, r2, r3  /* マスクのビットをすべて1にする */
        ldr     r3, [vcpu, #VCPU_IRQ_LINES]  /* vcpu構造体に用意してあった仮想割り込み用ビットを読み込む(下記参照) */
        orr     r2, r2, r3  /* 仮想割り込み用ビットを設定する */
        .else
        bic     r2, r2, r3  /* マスクをすべてクリアする(ホスト実行時にHYPモードへトラップしないように) */
        .endif
        mcr     p15, 4, r2, c1, c1, 0  /* HCRに更新した値を書き込む */
.endm

HCR_VIRT_EXCP_MASKとHCR_GUEST_MASKはarch/arm/include/asm/kvm_arm.hにあります。確かにIMOが1に設定されてそうですね。

/*
 * The bits we set in HCR:
 * TAC:         Trap ACTLR
 * TSC:         Trap SMC
 * TSW:         Trap cache operations by set/way
 * TWI:         Trap WFI
 * TIDCP:       Trap L2CTLR/L2ECTLR
 * BSU_IS:      Upgrade barriers to the inner shareable domain
 * FB:          Force broadcast of all maintainance operations
 * AMO:         Override CPSR.A and enable signaling with VA
 * IMO:         Override CPSR.I and enable signaling with VI
 * FMO:         Override CPSR.F and enable signaling with VF
 * SWIO:        Turn set/way invalidates into set/way clean+invalidate
 */
#define HCR_GUEST_MASK (HCR_TSC | HCR_TSW | HCR_TWI | HCR_VM | HCR_BSU_IS | \
                        HCR_FB | HCR_TAC | HCR_AMO | HCR_IMO | HCR_FMO | \
                        HCR_SWIO | HCR_TIDCP)
#define HCR_VIRT_EXCP_MASK (HCR_VA | HCR_VI | HCR_VF)

これでゲスト動作時のハードウェア割り込みの制御についてはわかりました。ついでにconfigure_hyp_roleで設定していた、仮想割り込みについても調べてみましょう。

再びリファレンスマニュアルを読んでみると*2、同じくHCRレジスタのHCR.VIとHCR.VFで仮想割り込みを挿入できることがわかります。上記HCR_VIRT_EXCP_MASKでマスクしているビットですね。

上記アセンブリにあったVCPU_IRQ_LINES

  DEFINE(VCPU_IRQ_LINES,        offsetof(struct kvm_vcpu, arch.irq_lines));

と定義されており、kvm_vcpu->arch.irq_linesを参照していることがわかります。 このメンバ変数irq_linesはvcpu_interrupt_line関数で設定されています。この関数の呼び出し元はkvm_vm_ioctl_irq_lineなので、qemuのデバイスエミュレータから仮想割り込みを挿入したい時に使われているのだと思います。

なおリファレンスマニュアルによると、仮想割り込みを挿入してもゲスト側で割り込み禁止にしていた場合*3は、ゲストが割り込みを許可するまで割り込みはペンディングされるようです。Intel VT-xと同じですね。

*1:B1.8.4 Processor mode for taking exceptions

*2:B1.8.11 Virtual exceptions in the Virtualization Extensions

*3:CPSR.Iを1にしているとき

LWNのKVM/ARMの記事の簡単なまとめ

LWNにSupporting KVM on the ARM architectureという記事が公開されていたので、ざっくりとまとめてみました。著者の一人はChristoffer DallというKVM/ARMの開発者の方です。

※書き終わってから、記事はまだ無料公開されているわけではないことを思い出したので、前半だけ公開することにします。もし続きが気になる方はLWNを購読してください(宣伝)。 どうやら無料公開されたようなのでこちらも全文公開します。もちろん正しい情報を得たい方はLWNの記事をご参照ください。

注意:私はあまりARMアーキテクチャは詳しくないので、解釈が間違っているかもしれません。

HYPモード

  • Intel VT-xの場合はringと直交するroot/non-root operationが導入され、ホストはroot、ゲストはnon-rootで動作する
  • ARMでは、既存のUSR/SVCモード等*1に追加する形で、ハイパーバイザ用のHYPモードが導入された
    • HYPモードはカーネルが動作するSVCモードより権限が高い
  • USR/SVCモードにはページテーブルベースレジスタ(x86のCR3相当)が2つあってユーザ/カーネルを分離できるがHYPモードには一つしかない
    • TTBR0とTTBR1
    • (とはいえ、元々Linuxは2つのうちの1つしか使ってなかったような?)
  • SVCモード(カーネル)のコードを再利用するKVMとはあまり相性が良くない
    • カーネルSVCモードとHYPモードの両方で動かす必要があるため
    • ハイパーバイザをHYPモードだけで動かせるXenの方が相性が良い?

アドレス変換

  • EPTのように、ゲスト物理→ホスト物理変換(ステージ2変換)のテーブルを追加

KVM/ARMの設計

動作概要

  • qemuを使うの点は他のアーキテクチャと同じ
  • qemuがioctl(KVM_VCPU_RUN)すると最終的にHVC命令を発行してHYPモード移行する
  • そこでKVM/ARMはゲストのコンテキストを準備してUSR/SVCモードにジャンプする
  • 割り込みやステージ2変換のフォールトやセンシティブ命令が発行されたりするとHYPモードに戻る(トラップする)
  • KVM/ARMはコンテキストをホストのものに戻してSVCモードに移行する
  • HYPモードへトラップした理由を調べてエミュレーション等を行なう点も他のアーキテクチャと同じ
  • 割り込み(要調査) 調査しました→ [id:kvm:20130711:1373554471]
    • VM実行中に割り込みが起きるとHYPモードにトラップする
    • (この時点で割り込みは止められている)
    • 状態をホストのものに戻し(SVCモードに移行するってこと?)、再度割り込みを許可する
    • 再度、割り込みが起きるが今度はHYPモードにトラップせず(そうならないように設定している?)、ホストの割り込みハンドラ(おそらくIRQモード)が実行される
    • この2回目の割り込みのオーバヘッドは(ゲスト→ホスト切り替えのコストに比べると)無視出来るほど小さい(らしい)

ブート

  • ブートローダは常に(CPUが対応していれば)HYPモードでカーネルを起動する
  • HYP対応カーネルの場合、KVM/ARM等のために小さなスタブハンドラを仕込む
  • HYP対応でない場合でも、明示的にSVCモードに移行しているので問題ない
    • そういえば確かにそんなコードがあったような
  • カーネルが圧縮されている場合
    • 解凍前にスタブを仕込む
    • SVCモードに移行してキャッシュ(MMU)を有効にする
      • キャッシュが効いてないと遅い
      • HYPモードでMMUを有効にしようとするとLPAE対応しないといけないのでしんどい
    • 解凍する
    • スタブを使ってHYPモードに戻ってカーネルを起動
  • スタブの役割
    • KVM/ARM等サブシステムにHYPモード用の割り込みベクタのアドレスを変更できるようにする
      • HYP Vector Base Address Register (HVBAR)
    • HYPモード用のページテーブルもストレートマッピングしておくことで、サブシステムが指定した関数を簡単にHYPモードで実行可能になる
      • これはブート後も同じはず
    • HVBARはブート後にはHYPモード用の適切な割り込みベクタを指すように設定される
  • HYPモード用の割り込みベクタ
    • ゲストからHYPモードへ戻ってきた時も、ホストでHVCを呼んでHYPモードへトラップした時も同じハンドラを実行する
    • ステージ2ページテーブルレジスタのVMIDフィールドを見て、ゲスト(VMID>0)かホスト(VMID==0)を判断する

仮想GICとタイマ

  • 古いARMではタイマへのアクセスはMMIOで単に(qemuで)エミュレーションするだけだと、とても遅い
    • ホストカーネルでエミュレーションしてもまだ遅い
  • ARMv7(の仮想化拡張では?)仮想/物理カウンタと仮想/物理タイマが導入された
    • 仮想カウンタ/タイマはどのモードでもアクセス可能
    • 物理カウンタ/タイマはHYPモードでのみアクセス可能
  • なので、仮想カウンタ/タイマを使う限りにおいてはHYPモードへトラップしない
  • 疑問: 仮想カウンタ/タイマを使うようにゲストカーネルを書き換えないといけない?それとも仮想という名前がついているけど、実際はゲストは気にしなくても良いもの?
  • GICにはdistributorという機能があり、割り込みのマスクや割り込みの優先度設定や割り込みのアフィニティ等を制御する機能をもつ
  • 通常のGICでは、割り込み後のGICへのackやEOI毎にHYPモードへトラップするためオーバヘッドが生じる
  • VGICはトラップすることなく仮想割り込みへのackやEOIを可能とする
  • またKVM/ARM(HYPモード?)でしかアクセスできない仮想制御インタフェースが追加され、仮想割り込みを発生させるときに使われる
  • distributorへアクセスする頻度はそれほど高くないため、仮想distributorは提供されていない
  • KVM/ARMはホストカーネル内でGICエミュレーションを行なう
  • ハードウェア割り込みが発生した場合は常にHYPモードへトラップし、ホストの割り込みハンドラが割り込みを処理する
  • VGICも仮想タイマも、物理割り込みを仮想割り込みとしてVMへ割り込みをかけることはできない
  • そのため、VMのタイマ割り込みはホストがハンドルしてKVM/ARMが仮想割り込みとしてゲストに割り込みをかける

*1:割り込み用にIRQやABTといったモードも存在する

Asynchronous page fault解析

はじめに

Linux-2.6.38でマージされたAsynchronous page fault(非同期ページフォールト、以降APFと略す)について調べてみました。

(追記:ゲストのプロセス切り替え可能判定の間違いを修正しました。)

Asynchronous page faultとは?

近代的なOSでは、ページフォールト時にディスクアクセス(I/O)が必要になる場合は、別のプロセスにCPUを割り当てることで、I/O待ちの間もCPUを有効利用しようとします。KVMゲストの場合も同じで、ゲストカーネルが(結果的に)ホストにI/O要求を出した後、別のプロセスをディスパッチします。これが可能なのは、ゲストカーネル自身がI/Oを発行するからです。

しかしながら、仮想環境ではゲストカーネルが知らないところで(仮想でない)I/Oが発生することがあります。その一つが、ゲストメモリがホストでスワップアウトされていた場合です。ホストでスワップアウトされていたページにゲストがアクセスしたときには、ゲストには透過な状態でホストでI/Oが発生します。その場合、CPUは当該ゲスト(VCPU)から奪われ、別のVCPUやホストユーザプロセスに割り当てられます。ですが、ゲストの中にはCPU割り当て待ちの別のプロセスが存在するかもしれず、ゲストから見ると不当にCPUが割り当てられない状態になるかもしれません(もちろん当該VCPUのタイムスライスが残っていることが前提)。

KVMに実装されたAPFとは、この不公平を解消するものです。ゲストが知らないホストのI/Oが必要になる場合に、ゲストに処理を戻すことで、ゲストカーネルが別プロセスをディスパッチする機会を与えます。もしディスパッチ可能であれば、当該ゲストはCPUを継続的に利用することができます。これにより、当該ゲストのスループットは上がるはずです*1

より詳しくは、KVM forum 2010でのGleb Natapov氏の発表資料を参照ください。とても解りやすいです。

対象バージョン

予備知識

  • CPUID
    • KVMの準仮想化機能の有無をゲストに知らせるときに使う
    • KVMが使えるかどうか調べるとき(grep vmx /proc/cpuinfo)のアレ
    • 仮想化環境ではハイパーバイザが柔軟に設定可能
  • MSR
    • CPU固有の機能のために用意されたレジスタ
    • 仮想化環境ではハイパーバイザが柔軟に設定可能。また書き込み時にVM exitを起こすことも可能
    • APFではゲストからホストへ情報を送るときに使用される(後述)

動作概要

  1. 初期化
    • ホスト・ゲストでお互いに機能の有無を調べる
  2. ページフォールト
    • ホストでスワップアウトされたページにゲストがアクセスするとVM exitが起きる
    • ページの準備(スワップイン)をworkqueueに任せて、ゲストに処理を戻す
    • ゲストは当該プロセスを待ち状態にして別のプロセスをディスパッチする
  3. I/O発行とI/O完了
    • workqueueは普通にページアクセスしてスワップインを起こさせる
    • ページの準備が完了したら、その旨をKVM本体に伝える(完了キューにつなぐ)
    • KVM本体はメインループで定期的にチェックすることで準備が完了したことを知る
  4. ゲストへの通知とページフォールト(再)
    • ホストはページが準備完了した旨をページフォールトを起こすことでゲストに知らせる
    • ゲストは待ち状態のプロセスを起こす

大雑把にはこのような動作をしますが、実際にはページフォールトが起きた状態によって動作が変わってきます。以下では、その辺りを含めた詳細を説明しています。

初期化

APFは準仮想化機能です。ホストはゲストがAPF機能を持っていることを確認してから初期化を行ないます。

動作概要は以下の通り。

  1. ホスト
    • qemuKVMにCPUIDを設定する(ioctl)。このときAPFが有効であることを含めておく
    • KVMはそのCPUIDを仮想マシン(VMCS)に設定する
  2. ゲスト
    • カーネルはブート時にCPUIDを調べ、APFが有効になっていた場合、MSRの当該レジスタに書き込む(wrmsr)ことでホストにACKする
    • このとき、書き込む内容にゲストカーネルが割り当てているCPU毎の変数(per cpu var、以降APF要因変数と呼ぶ)のアドレス含める
  3. ホスト
    • wrmsrによりVM exitが発生し、ゲストがAPFに対応していることを知る
    • APF要因変数のアドレスを覚えておく(後で、ページフォールト時に利用する)

以降は重要な部分だけコードを見ながら説明していきます。ホストでCPUIDを設定するところは省略して、ゲストがブートする部分からです。

まずゲストカーネルはホストのAPFが有効であることがわかると、ページフォールト割り込みハンドラを、(do_)page_fault()から(do_)async_page_fault()へ変更します。

static void __init kvm_apf_trap_init(void)
{
        set_intr_gate(14, &async_page_fault);
}

(do_)async_page_fault()はページフォールト時にAPFかどうかチェックして、APFならばAPF要因毎のハンドラ(後述)を呼び出し、そうでなければdo_page_fault()を呼びます*2

続いて、CPU毎の初期化処理の途中でKVMゲスト向け初期化関数kvm_guest_cpu_init()が呼ばれます。

void __cpuinit kvm_guest_cpu_init(void)
{
        if (!kvm_para_available())
                return;

        if (kvm_para_has_feature(KVM_FEATURE_ASYNC_PF) && kvmapf) {
                u64 pa = __pa(&__get_cpu_var(apf_reason));

#ifdef CONFIG_PREEMPT
                pa |= KVM_ASYNC_PF_SEND_ALWAYS;
#endif
                wrmsrl(MSR_KVM_ASYNC_PF_EN, pa | KVM_ASYNC_PF_ENABLED);
                __get_cpu_var(apf_reason).enabled = 1;
                printk(KERN_INFO"KVM setup async PF for cpu %d\n",
                       smp_processor_id());
        }
}

kvm_para_has_feature()でAPFが有効であることがわかると、__get_cpu_var(apf_reason)でAPF要因変数のアドレスにフラグを加えたものをMSRに書き込みます*3。このapf_reasonは、前述のゲストのページフォールトハンドラの分岐判定に使われます。

フラグに関してですが、CONFIG_PREEMPTが有効のときにKVM_ASYNC_PF_SEND_ALWAYSが設定されています。CONFIG_PREEMPTが有効になっている場合は、ここの説明のとおり、カーネル内コードを実行中にプロセス切り替え可能です。逆に有効でない場合は、もしAPFでホストからプロセス切り替えの機会を与えられても困ります。KVM_ASYNC_PF_SEND_ALWAYSをoffにしておくと、ページフォールト時にゲストのCPLが0の場合は、ホストはAPFでゲストに処理を戻さないようになります(後述)。

wrmsrl()でMSR書き込み専用命令が呼ばれるとVM exitが起き、処理がホストに戻ります。ホストでは最終的にkvm_pv_enable_async_pf()が呼ばれます。

static int kvm_pv_enable_async_pf(struct kvm_vcpu *vcpu, u64 data)
{
        gpa_t gpa = data & ~0x3f;

        /* Bits 2:5 are resrved, Should be zero */
        if (data & 0x3c)
                return 1;

        vcpu->arch.apf.msr_val = data;

        if (!(data & KVM_ASYNC_PF_ENABLED)) {
                kvm_clear_async_pf_completion_queue(vcpu);
                kvm_async_pf_hash_reset(vcpu);
                return 0;
        }

        if (kvm_gfn_to_hva_cache_init(vcpu->kvm, &vcpu->arch.apf.data, gpa))
                return 1;

        vcpu->arch.apf.send_user_only = !(data & KVM_ASYNC_PF_SEND_ALWAYS);
        kvm_async_pf_wakeup_all(vcpu);
        return 0;
}

ここでは、渡されたAPF要因変数のアドレスを登録し、素早く書き込みできるようにkvm_gfn_to_hva_cache_init()でキャッシュを設定しています。このキャッシュはゲスト物理アドレス→ホスト仮想アドレス(qemuプロセスのアドレス)変換結果を保持するもので、後述のkvm_write_guest_cached()で使われます。

またKVM_ASYNC_PF_SEND_ALWAYSフラグがoffのとき、send_user_only = trueと設定され、ゲストがユーザモードで動いているときだけAPFの割り込みが送られるようになります。

ページフォールト

ホストでスワップアウトされたページにゲストがアクセスした場合は、VM exitが起きてホストに処理が移ります。まずはそちらの処理内容から説明していきます。

ホスト
  1. KVMカーネルモジュールのページフォールトハンドラ(tdp_page_fault())でI/Oが必要になるかチェックする
    • __get_user_pages_fast()というI/Oが必要な場合はエラーを返す関数を使う
  2. I/Oが必要な場合、workqueueにI/Oを実行する仕事を登録する
    • 登録先workqueueはCPU毎に用意されるシステムグローバルなもの([events/?]という名前をもつスレッド)
  3. VM entry時にゲストにページフォールト割り込みが発生するように設定する
    • APF要因変数に当該ページがホストでスワップアウトされている旨を書いておく
  4. VM entryする
    • しない場合もある(後述)

ページフォールトハンドラはtry_async_pf() → gfn_to_pfn_async() …→ __get_user_pages_fast() と呼び出し、I/Oなしで当該ページが存在するかチェックします。もし存在しなければ、kvm_arch_setup_async_pf() → kvm_setup_async_pf()でworkqueueの初期化をします(workqueueの処理内容は後述)。その後、kvm_arch_async_page_not_present()でゲストにAPF割り込みをかけようとします。

void kvm_arch_async_page_not_present(struct kvm_vcpu *vcpu,
                                     struct kvm_async_pf *work)
{
        struct x86_exception fault;

        trace_kvm_async_pf_not_present(work->arch.token, work->gva);
        kvm_add_async_pf_gfn(vcpu, work->arch.gfn);

        if (!(vcpu->arch.apf.msr_val & KVM_ASYNC_PF_ENABLED) ||
            (vcpu->arch.apf.send_user_only &&
             kvm_x86_ops->get_cpl(vcpu) == 0))
                kvm_make_request(KVM_REQ_APF_HALT, vcpu);
        else if (!apf_put_user(vcpu, KVM_PV_REASON_PAGE_NOT_PRESENT)) {
                fault.vector = PF_VECTOR;
                fault.error_code_valid = true;
                fault.error_code = 0;
                fault.nested_page_fault = false;
                fault.address = work->arch.token;
                kvm_inject_page_fault(vcpu, &fault);
        }
}

まずゲストにAPF割り込み可能かどうかチェックします。ゲストで有効になっていないか、send_user_only=trueかつゲストがカーネルモードのときにページフォールトが起きた場合は、割り込みをかけません。代わりにVCPUをhaltさせます。

Halt状態のVCPUは、溜まっているシグナルの処理などは実行しますが、条件が整うまでVM entryしなくなります。APFの場合は、workqueueで実行されているI/Oが完了するまでhaltのままになります。

APF割り込みに可能な場合は、apf_put_user()でAPF要因変数にページフォールト要因(KVM_PV_REASON_PAGE_NOT_PRESENT)を書き込み、KVMに割り込みを起こさせるように設定しています。なお、APF要因変数はqemuプロセスのメモリ上(のゲストのメモリ領域)に見えているので、書き込みにはcopy_to_user()を用いることになります。

ゲスト
  1. 割り込みハンドラ(do_async_page_fault())が呼ばれる
  2. カレントプロセスをwaitqueueに入れて、可能ならプロセス切り替え(schedule())を行なう
  3. プロセス切り替えできない場合は、CPUをhaltさせる

まずページフォールトハンドラです。

dotraplinkage void __kprobes
do_async_page_fault(struct pt_regs *regs, unsigned long error_code)
{
        switch (kvm_read_and_reset_pf_reason()) {
        default:
                do_page_fault(regs, error_code);
                break;
        case KVM_PV_REASON_PAGE_NOT_PRESENT:
                /* page is swapped out by the host. */
                kvm_async_pf_task_wait((u32)read_cr2());
                break;
        case KVM_PV_REASON_PAGE_READY:
                kvm_async_pf_task_wake((u32)read_cr2());
                break;
        }
}

kvm_read_and_reset_pf_reason()で、ホストが設定したAPF要因変数を調べて分岐します。ここでは、KVM_PV_REASON_PAGE_NOT_PRESENTがセットされてるはずなので、kvm_async_pf_task_wait()が呼ばれます。

void kvm_async_pf_task_wait(u32 token)
{
        u32 key = hash_32(token, KVM_TASK_SLEEP_HASHBITS);
        struct kvm_task_sleep_head *b = &async_pf_sleepers[key];
        struct kvm_task_sleep_node n, *e;
        DEFINE_WAIT(wait);
        int cpu, idle;

        cpu = get_cpu();
        idle = idle_cpu(cpu);
        put_cpu();

        spin_lock(&b->lock);
        e = _find_apf_task(b, token);
        if (e) {
                /* dummy entry exist -> wake up was delivered ahead of PF */
                hlist_del(&e->link);
                kfree(e);
                spin_unlock(&b->lock);
                return;
        }

        n.token = token;
        n.cpu = smp_processor_id();
        n.mm = current->active_mm;
        n.halted = idle || preempt_count() > 1;
        atomic_inc(&n.mm->mm_count);
        init_waitqueue_head(&n.wq);
        hlist_add_head(&n.link, &b->list);
        spin_unlock(&b->lock);

        for (;;) {
                if (!n.halted)
                        prepare_to_wait(&n.wq, &wait, TASK_UNINTERRUPTIBLE);
                if (hlist_unhashed(&n.link))
                        break;

                if (!n.halted) {
                        local_irq_enable();
                        schedule(); /* ※プロセス切り替え */
                        local_irq_disable();
                } else {
                        /*
                         * We cannot reschedule. So halt.
                         */
                        native_safe_halt();
                        local_irq_disable();
                }
        }
        if (!n.halted)
                finish_wait(&n.wq, &wait);

        return;
}

長いですが、コメントに書いた「※プロセス切り替え」の箇所が一番正常なパスです。ユーザモードでページフォールトが起きたときは、ほとんどの場合は、プロセスを待ち状態に設定した後、ここで他のプロセスをディスパッチするはずです。

しかし、コードを見てみるとschedule()ではなく、native_safe_halt()が呼ばれる、つまりCPUをhaltさせているパスもあります。どういった場合にこうなるのでしょうか?

[修正]native_safe_halt()が呼ばれるのはn.halted=trueのときです。そうなるのは、まずカレントプロセスがidleプロセスのときです。カレントプロセスがidleプロセスということは、他にCPUを割り当てるべきプロセスがいないということなので、haltにするのは妥当です。

次にCPUを横取り可能できるかどうか調べています。preempt_count() > 1の箇所です。(通常ならば、preempt_count() > 0でチェックするところなのですが、上の方でspin_lock()を呼んでいるのでそれを考慮して> 1となっています。)この条件式がtrueになるのは、割り込みコンテキストで動いているときと、ロックを保持しているときです。このカウンタがどう扱われているかはこちらを参照ください。またロックを保持したままCPUを横取りするとまずい理由はpreempt-locking.txtを参照してください。[/修正]

Haltした場合は、ホストがそれを検出可能なので、別の仮想マシンやホストプロセスにCPUがディスパッチされるはずです。

I/O発行とI/O完了

ここでは、実際にI/Oを発行させているworkqueueの設定と完了処理を見ていきます。

  1. workqueueにはasync_pf_execute()が設定される
  2. カーネルスレッド([events/?])がasync_pf_execute()を呼ぶ
    • APF I/O用のオブジェクトを割り当てる
    • get_user_pages()を呼ぶ。結果I/O(スワップイン)が発生する
  3. I/Oが完了したらAPF I/O完了リストに上記オブジェクトをつなぐ

workqueueはプロセスコンテキストで動いておりsleep可能です。つまり、普通のユーザプロセスでページフォールトが発生した状態とほぼ同じ状況になります。

APF I/O完了リスト登録は以下の通りで、後述のVCPUループ(KVMのメインループ)でこのリストがチェックされます。

list_add_tail(&apf->link, &vcpu->async_pf.done);

ゲストへの通知とページフォールト(再)

当該ページがスワップインされたら、その旨をゲストに通知し、ゲストは当該プロセスを起床させます。

  1. ホスト
    • VCPUのループでI/O完了を定期的にチェックする
    • 完了していたら、KVMページフォールトハンドラを再度呼び出してSPTEを設定する
    • APF要因変数のフラグを立ててゲストに割り込みをかける
  2. ゲスト
    • 割り込みハンドラが呼ばれる
    • 当該プロセス(プロセスコンテキストで実行中のカーネルカーネルスレッドもあり得る)を起こす。

完了チェックは__vcpu_run()のループ内のkvm_check_async_pf_completion()で行なわれます。もし完了していたら、kvm_arch_async_page_ready() → tdp_page_fault()と呼び、SPTEを設定します。(SPTEの設定に関しては d:id:kvm:20110514 を参照ください。)

その後、kvm_check_async_pf_completion()はkvm_arch_async_page_present()を呼んで、APF要因変数をKVM_PV_REASON_PAGE_READYに設定し、ゲストに割り込みがかかるように設定します(KVM_PV_REASON_PAGE_NOT_PRESENTの場合とだいたい同じなのでコードは省略)。なお、ここでVCPUのhalt状態が解除され、VM entryするようになります。

割り込みを受けたゲストは、do_async_page_fault() → kvm_async_pf_task_wake() → apf_task_wake_one()と呼びます。

static void apf_task_wake_one(struct kvm_task_sleep_node *n)
{
        hlist_del_init(&n->link);
        if (!n->mm)
                return;
        mmdrop(n->mm);
        if (n->halted)
                smp_send_reschedule(n->cpu);
        else if (waitqueue_active(&n->wq))
                wake_up(&n->wq);
}

起こす対象がhaltしてる場合は、この割り込みハンドラは別の(V)CPUで動いているはずなので、IPIでCPUを起こします(smp_send_reschedule())。そうでなければ、当該プロセスを起床させます。

おわりに

いろいろと端折っていますが、APFの動作をひと通り見ていきました。

思ったより複雑で、カーネルやハードウェアに関する知識がないと理解するのが難しかったです。非同期処理を実現するのはやっぱり大変ですね。

以下は、コードを読むときに参考にしたサイト、コミットやコードの関数定義などへのリンクを付録として付けました。(本文中でリンクを張ると本文の編集がやりにくかったので付録にしました。)APFを読んでみようと思った人の参考になれば幸いです。

*1:注:ホスト視点では、他にディスパッチ可能なプロセスがいる場合はスループットは変わらないかもしれません。

*2:つまり、通常のページフォールト時のオーバヘッドが少し増える。

*3:apf_reasonは64bitでalignされているので、下位6bitをフラグに使えます。

qemuのtrace機能

はじめに

qemuには内部の処理をtraceするための機能があります。この機能を使うとprintfのように簡単でprintfより軽量なデバッグ出力を追加することができます。

まとめ

  • 用途はデバッグ(printfの代わり)
  • コンパイル時に機能をon/off指定する必要がある
    • 普通はoffになっている
  • 個々のトレースポイントは動的にon/off可能
    • ただしoff時にもオーバヘッドあり
  • 出力フォーマットにLTTngやDTraceがある
  • 実装はシンプル(面白味に欠けるともいう)

環境

参考リンク

関連ファイル

  • trace-events: trace定義ファイル(仮称)
  • scripts/tracetool: 指定バックエンド用のtrace.[ch]をコンパイル時に生成するスクリプト
  • simpletrace.[ch]: simpletraceバックエンドのソースコード
  • scripts/simpletrace.py: simpletraceバックエンドのデータを解析・整形出力するスクリプト

準備(トレースポイントを追加する)

qemuのtrace機能は何も出力しなくてもオーバヘッドがあるので、ディストリビューションのパッケージでは普通はoffになっていると思います。trace機能を使うためにはコンパイル時にオプションを明示的に指定しなければいけません。

configureの当該オプションは以下の通り。

$ ./configure --help |grep trace
  --enable-trace-backend=B Set trace backend
                           Available backends: nop simple stderr ust dtrace
  --with-trace-file=NAME   Full PATH,NAME of file to store traces
                           Default:trace-<pid>

バックエンドとしてはsimple, stderr, ust(LTTng), dtraceをサポートしているようです。nopを指定するとソースコードにはトレースポイントは埋めこまれません(つまりオーバヘッドなし)。それ以外を指定するとトレースポイントが埋めこまれます。

トレースポイントはtrace定義ファイル(trace-events)に定義された通り動作します。デフォルトの定義ファイルではすべて無効になっているので、何も出力しません。trace定義ファイルで有効にしてコンパイルするか、qemu動作中にモニタで有効にすると出力するようになります。

ここでは、自分でトレースポイントを追加してみます。qcow2ディスクでbacking storeとCoWされたディスクからデータを読み出す割合を調べることを目的としてblock/qcow2.cを以下のように修正しました。

diff --git a/block/qcow2.c b/block/qcow2.c
index 75b8bec..facc921 100644
--- a/block/qcow2.c
+++ b/block/qcow2.c
@@ -29,6 +29,7 @@
 #include "block/qcow2.h"
 #include "qemu-error.h"
 #include "qerror.h"
+#include "trace.h"
 
 /*
   Differences with QCOW:
@@ -486,6 +487,7 @@ static void qcow2_aio_read_cb(void *opaque, int ret)
                 acb->sector_num, acb->cur_nr_sectors);
             if (n1 > 0) {
                 BLKDBG_EVENT(bs->file, BLKDBG_READ_BACKING_AIO);
+                trace_read_backing_aio(bs, acb, acb->sector_num, acb->cur_nr_se
ctors);
                 acb->hd_aiocb = bdrv_aio_readv(bs->backing_hd, acb->sector_num,

                                     &acb->hd_qiov, n1, qcow2_aio_read_cb, acb);

                 if (acb->hd_aiocb == NULL) {
@@ -542,6 +544,8 @@ static void qcow2_aio_read_cb(void *opaque, int ret)
         }
 
         BLKDBG_EVENT(bs->file, BLKDBG_READ_AIO);
+        trace_read_aio(bs, acb, (acb->cluster_offset >> 9) + index_in_cluster,
+                       acb->cur_nr_sectors);
         acb->hd_aiocb = bdrv_aio_readv(bs->file,
                             (acb->cluster_offset >> 9) + index_in_cluster,
                             &acb->hd_qiov, acb->cur_nr_sectors,

この修正で、backing storeへのI/Oリクエストがあればtrace_read_backing_aioが、それ以外ではtrace_read_aioのトレースポイントが呼び出されるようになります。

これに加え、対応する定義をtrace定義ファイルにも追加します。

read_aio(void *bs, void *acb, int64_t sector_num, int nb_sectors) "bs %p acb %p sector_num %"PRId64" nb_sectors %d"
read_backing_aio(void *bs, void *acb, int64_t sector_num, int nb_sectors) "bs %p acb %p sector_num %"PRId64" nb_sectors %d"

今回はsimpleバックエンドを使うので以下のようにコンパイルします。

./configure --target-list=x86_64-softmmu --enable-trace-backend=simple
make

makeの途中でtrace.[ch]ファイルが生成されます。このエントリの下の方で軽く解析しています。

実演

trace機能のオプションは出力ファイルを指定するための-traceだけです*1。helpメッセージは以下の通り。

$ ./x86_64-softmmu/qemu-system-x86_64 --help |grep trace
-trace
                Specify a trace file to log traces to

。。。という機能があるのですが、バグなのかうまく動かず、常にconfigureのデフォルトであるtrace-が使われてしまいました。今回は特に困らないなので気にしないことにします。

追記: -trace file=trace.logで動きました。helpが不完全なだけのようです。

qemu.orgにあるlinux-0.2.imgをbacking storeに設定してtest.imgを生成して、2回VMを起動し、ディスクへのアクセスがどう変化するかを調べました。実際にやったことを以下の通り。

$ ./qemu-img create -f qcow2 -b linux-0.2.img test.img
Formatting 'test.img', fmt=qcow2 size=20971520 backing_file='linux-0.2.img' encryption=off cluster_size=0 
$ ls -s test.img
140 test.img
$ ./x86_64-softmmu/qemu-system-x86_64 -hda test.img -curses
# シェルのプロンプトが出たあとすぐにVMを終了させる
$ ls -s test.img
268 test.img
$ ./x86_64-softmmu/qemu-system-x86_64 -hda test.img -curses
# 同上
$ ls -1 -srt |tail -3
  268 trace-320
  268 test.img
  268 trace-393

trace-320が一回目のVMの起動によって出力されるトレース内容、trace-393が二回目のものです。ファイルの中身はバイナリですが、simpletrace.pyを使うと人間が読める形式で出力してくれます。

$ python scripts/simpletrace.py trace-events trace-320 |tail
read_backing_aio 274.457 bs=0x311dfb0 acb=0x3379610 sector_num=0x58ae nb_sectors=0x32
read_backing_aio 4830.300 bs=0x311dfb0 acb=0x3379610 sector_num=0x5326 nb_sectors=0x2
read_backing_aio 303.194 bs=0x311dfb0 acb=0x3379610 sector_num=0x402e nb_sectors=0x2
read_backing_aio 175.930 bs=0x311dfb0 acb=0x3379610 sector_num=0x8ae0 nb_sectors=0x2
read_backing_aio 344.556 bs=0x311dfb0 acb=0x3379610 sector_num=0x4018 nb_sectors=0x2
read_backing_aio 197.513 bs=0x311dfb0 acb=0x3379610 sector_num=0x4d54 nb_sectors=0x18
read_backing_aio 149.756 bs=0x311dfb0 acb=0x3379610 sector_num=0x4d6c nb_sectors=0x2
read_backing_aio 214.704 bs=0x311dfb0 acb=0x3379610 sector_num=0x4d6e nb_sectors=0x8
read_backing_aio 199.591 bs=0x311dfb0 acb=0x3379610 sector_num=0x4d76 nb_sectors=0x40
read_backing_aio 215.196 bs=0x311dfb0 acb=0x3379610 sector_num=0x4db6 nb_sectors=0x80

こんな感じです。どのセクタに何セクタ分のI/Oリクエストがあるかわかります。

このトレース出力をgrepして何回backing storeへアクセスがあるか調べてみます。

$ python scripts/simpletrace.py trace-events trace-320 |grep read_aio |wc -l 
0
$ python scripts/simpletrace.py trace-events trace-320 |grep read_backing_aio |wc -l 
4167
$ python scripts/simpletrace.py trace-events trace-393 |grep read_aio |wc -l         
12
$ python scripts/simpletrace.py trace-events trace-393 |grep read_backing_aio |wc -l 
4156

一回目のVM起動ではすべてbacking storeから、二回目は12回だけCoWされたディスクへのI/Oリクエストが発生していることがわかりました。おそらくファイルシステムメタデータ に更新があった箇所だけ本体の方にコピーが生成されたのでしょう(推測)。このやり方ではデータ転送量まではわからないですが、とりあえずCoWディスクのI/Oリクエスト数の変化はわかりましたのでよしとします*2

解析

ここでは、どういう仕組みで動いているのか調べてみます。コンパイル時に生成されるtrace.[ch]の当該部分は以下の通り。

static inline void trace_read_aio(void *bs, void *acb, int64_t sector_num, int nb_sectors)
{
    trace4(169, (uint64_t)(uintptr_t)bs,(uint64_t)(uintptr_t)acb,(uint64_t)(uintptr_t)sector_num,(uint64_t)(uintptr_t)nb_sectors);
}

static inline void trace_read_backing_aio(void *bs, void *acb, int64_t sector_num, int nb_sectors)
{
    trace4(170, (uint64_t)(uintptr_t)bs,(uint64_t)(uintptr_t)acb,(uint64_t)(uintptr_t)sector_num,(uint64_t)(uintptr_t)nb_sectors);
}

trace.hはこんな感じ。今回追加したトレースポイント用関数が定義され、中でtrace4関数を呼び出していることがわかります。第一引数はトレースポイントのIDのようです。

続いてtrace.cは以下の通り。

TraceEvent trace_list[] = {

/* snip */

{.tp_name = "spice_vmc_unregister_interface", .state=0},

{.tp_name = "read_aio", .state=1},

{.tp_name = "read_backing_aio", .state=1},

};

TraceEventはsimpletrace.hに定義されています。

typedef struct {
    const char *tp_name;
    bool state;
} TraceEvent;

stateは出力するかどうかを表わしていて、1なら出力です。trace定義ファイルでenableにしてあるread_aioとread_backing_aioは1、それ以外は0になっています*3

trace4関数はsimpletrace.cに定義してあります。

void trace4(TraceEventID event, uint64_t x1, uint64_t x2, uint64_t x3, uint64_t x4)
{
    trace(event, x1, x2, x3, x4, 0, 0);
}

trace関数を呼び出しているだけですね。そのtrace関数もsimpletrace.cに定義してあります。

static void trace(TraceEventID event, uint64_t x1, uint64_t x2, uint64_t x3,
                  uint64_t x4, uint64_t x5, uint64_t x6)
{
    TraceRecord *rec = &trace_buf[trace_idx];

    if (!trace_list[event].state) {
        return;
    }

    rec->event = event;
    rec->timestamp_ns = get_clock();
    rec->x1 = x1;
    rec->x2 = x2;
    rec->x3 = x3;
    rec->x4 = x4;
    rec->x5 = x5;
    rec->x6 = x6;

    if (++trace_idx == TRACE_BUF_LEN) {
        st_flush_trace_buffer();
    }
}

void st_flush_trace_buffer(void)
{
    if (trace_file_enabled) {
        flush_trace_file();
    }

    /* Discard written trace records */
    trace_idx = 0;
}

static void flush_trace_file(void)
{
    /* If the trace file is not open yet, open it now */
    if (!trace_fp) {
        trace_fp = fopen(trace_file_name, "w");
        if (!trace_fp) {
            /* Avoid repeatedly trying to open file on failure */
            trace_file_enabled = false;
            return;
        }
        write_header(trace_fp);
    }

    if (trace_fp) {
        size_t unused; /* for when fwrite(3) is declared warn_unused_result */
        unused = fwrite(trace_buf, trace_idx * sizeof(trace_buf[0]), 1, trace_fp);
    }
}

static bool write_header(FILE *fp)
{
    static const TraceRecord header = {
        .event = HEADER_EVENT_ID,
        .timestamp_ns = HEADER_MAGIC,
        .x1 = HEADER_VERSION,
    };

    return fwrite(&header, sizeof header, 1, fp) == 1;
}

stateが0なら何もしない。stateが1のときは、バッファが満杯になった時だけ出力するという動作のようです。(trace_file_enabledがfalseのときは出力しないようですが、とりあえず今回は無視します。)出力内容は、ファイルの最初にヘッダ用のレコードを出力して(write_header関数)、あとはバッファの中身を一度に出力しているだけです。

/** Trace buffer entry */
typedef struct {
    uint64_t event;
    uint64_t timestamp_ns;
    uint64_t x1;
    uint64_t x2;
    uint64_t x3;
    uint64_t x4;
    uint64_t x5;
    uint64_t x6;
} TraceRecord;

enum {
    TRACE_BUF_LEN = 64 * 1024 / sizeof(TraceRecord),
};

static TraceRecord trace_buf[TRACE_BUF_LEN];

レコード1つが8*64bit=64バイトで、1024回分バッファリングできるようです。

なおqemu終了時はatexit(3)でバッファの残りを出力しています。

/**
 * Flush the trace buffer on exit
 */
static void __attribute__((constructor)) st_init(void)
{
    atexit(st_flush_trace_buffer);
}

軽くソースコードを読んでみましたが、simpleという名前の通り簡単なつくりでした。カーネルのdynamic tracingのように無効時にオーバヘッドを減らすトリックもありません。それでも自前でprintfするよりは簡単で軽量なデバッグ出力を追加できそうです。

おわりに

実装的には面白味はあんまりなかったですが、qemuを改良する人にとっては便利な機能なのではないでしょうか。

バグらしきものも残っていますし、最適化の余地も残っているので、興味がある人はコントリビュートするのに良いかもしれません*4

*1:qemuコンソールにも機能がありますが、今回は触れません。

*2:まぁ適当なスクリプトを書けばすぐにわかりますが。

*3:このstateはqemuコンソールで変えることができます。tp_nameはコンソールで名前を指定してstateを変更するためのものです。

*4:ロードマップには一応ありますね。

VM間共有メモリivshmemを試してみる

はじめに

qemu(-kvm)にはVM間共有メモリ(Inter-VM shared memory: ivshmem)という機能があります。名前のとおりVM間の共有メモリを実現する機能です。

今回はこの機能を体験することが目的です。内部動作解析などは行なっていません。

How does ivshmem work?

以下のドキュメントを参考にしてください。

ざっくり説明すると、ホストのshmem(shm)をPCIバイスを介してゲストに見せる、ということになると思います*1。ゲストでUIOドライバを使うと、ユーザレベルのプログラムから共有メモリに(比較的)簡単にアクセスできるようになります。

環境

ゲスト用のファイルは以下のように用意されている前提で以降の説明をしていきます。

  • ゲストrootfs: /path/to/natty_root
  • ゲストカーネル: /boot/vmlinuz-2.6.38-8-generic
  • ゲストinitrd: /path/to/initrd.img-2.6.38-8-virtfs

qemu(-kvm)がivshmem機能をもっているかどうかは以下のように確かめます。(Ubuntu 2011.04ならばあるはずです。)

$ kvm -device '?' 2>&1|grep ivshmem
name "ivshmem", bus PCI

環境構築

単純にshmemをゲストに見せるだけという構成も可能ですが、それだけではVM間で同期が取れない(更新通知ができない)ので、今回はivshmem-serverという中継サーバを使う構成を試します。ivshmem-serverがイベント通知をサポートしてくれます*2

通常のVM環境に加えて必要なものは以下の通り。すべてivshmemツールに含まれています。

  • ivshmem-server
  • uio_ivshmem.ko(ゲスト用)
  • テストプログラム

ちなみにivshmemツールのディレクトリ構成は以下の通りです。

$ tree -d ivshmem
ivshmem
├── ivshmem-server
├── kernel_module
│   └── uio
├── scripts
├── startup_files
├── tests
│   ├── DumpSum
│   │   ├── Host
│   │   └── VM
│   ├── FTP
│   │   ├── Java
│   │   │   ├── old
│   │   │   └── org
│   │   │       └── ualberta
│   │   │           └── shm
│   │   └── VM
│   ├── Interrupts
│   │   └── VM
│   ├── Java
│   │   └── JNI
│   ├── Semaphores
│   │   ├── Host
│   │   └── VM
│   └── Spinlocks
│       ├── Host
│       └── VM
└── uio
    ├── benchmarks
    │   └── VM
    │       └── coyote
    └── tests
        ├── DumpSum
        └── Interrupts
            └── VM

34 directories

今回使うのは、ivshmem-server/, kernel_module/uio, tests/, uio/tests/Interrupts/VMです。

git cloneして必要なものをコンパイルします。

git clone git://gitorious.org/nahanni/guest-code.git ivshmem
cd ivshmem/
cd ivshmem-server/
make
cd -
cd kernel_module/uio
make
cd -
cd uio/tests/Interrupts/VM
cmake CMakeLists.txt
make

簡単に準備完了、と思ったのですが、ivshmem_server.cのコンパイルでエラーが出たので以下のように修正しました。

diff --git a/ivshmem-server/ivshmem_server.c b/ivshmem-server/ivshmem_server.c
index ae7a113..d187fa8 100644
--- a/ivshmem-server/ivshmem_server.c
+++ b/ivshmem-server/ivshmem_server.c
@@ -62,7 +62,10 @@ int main(int argc, char ** argv)
         exit(-1);
     }
 
-    ftruncate(s->shm_fd, s->shm_size);
+    if (ftruncate(s->shm_fd, s->shm_size) == -1) {
+        perror("ftruncate");
+        exit(1);
+    }
 
     s->conn_socket = create_listening_socket(s->path);
 

次は必要なファイルをゲストrootfsにインストールするのですが、せっかくなのでvirtfsを活用して以下のようにしました。

sudo cp ivshmem/kernel_module/uio/uio_ivshmem.ko /lib/modules/2.6.38-8-generic/kernel/drivers/uio/
sudo depmod -a
sudo mount --bind /lib/modules /path/to/natty_root/lib/modules
sudo mount --bind ivshmem/uio/tests/Interrupts/VM /path/to/natty_root/root/tests

カーネルモジュール(uio_ivshmem.ko)はホストへインストールして、モジュールディレクトリをゲストrootfsへbindマウントします(ゲストもホストと(ほぼ)同じ環境なのでこれで問題ありません)。同じくテストプログラムがあるディレクトリもbindマウントします。非常に簡単で良いですね*3

あとゲストでlspciを使いたかったのでpciutilsもインストールしました。

sudo chroot /path/to/natty_root/ apt-get install pciutils # エラーがでますが気にしない

実際に動かしてみる

まずはivshmem_serverを起動させます。

$ ./ivshmem_server
listening socket: /tmp/ivshmem_socket
shared object: ivshmem
shared object size: 1048576 (bytes)
vm_sockets (0) =

Waiting (maxfd = 4)

ivshmemという名前のshmemファイル(/dev/shm/ivshmem)を作って、/tmp/ivshmem_socketでqemuからの接続を待っているのがわかります。共有メモリのサイズは1 MB(デフォルト値)です。

次にVMを起動します。

sudo kvm -enable-kvm -kernel /boot/vmlinuz-2.6.38-8-generic -initrd /path/to/initrd.img-2.6.38-8-virtfs -append 'mount_tag=natty single' -virtfs local,path=/path/to/natty_root,mount_tag=natty,security_model=none -curses -device ivshmem,size=1,chardev=ivshmem -chardev socket,path=/tmp/ivshmem_socket,id=ivshmem

-device ivshmem,size=1,chardev=ivshmem -chardev socket,path=/tmp/ivshmem_socket,id=ivshmemがivshmemを使うのに必要な引数です。

VMを起動すると、ivshmem_serverが接続を受け入れた旨を出力しているのがわかります。

#(cont.)

[NC] new connection
increasing vm slots
[NC] Live_vms[0]
        efd[0] = 6
[NC] trying to send fds to new connection
[NC] Connected (count = 0).
Live_count is 1
vm_sockets (1) = [5|6]

Waiting (maxfd = 5)

続いてもう一つVMを起動します。(上記と同じ引数で起動しました。つまり同じrootfsを参照しています。試しにやってみたところうまくいったのでこのまま作業を続けましたが、別々のrootfsを用意した方が良いと思います*4。)

#(cont.)

[NC] new connection
[NC] Live_vms[1]
        efd[0] = 8
[NC] trying to send fds to new connection
[NC] Connected (count = 1).
[UD] sending fd[1] to 0
        efd[0] = [8]
Live_count is 2
vm_sockets (2) = [5|6] [7|8]

Waiting (maxfd = 7)

ivshmem_serverがもう一つ接続を受け入れたことがわかります。

今度はゲストの中の作業です(2つのVMをkvm0, kvm1と呼ぶことにします)。lspciとdmesgの出力を見てみます。

root@kvm0:~# lspci -v -s 00:05.0
00:05.0 RAM memory: Red Hat, Inc Device 1110
        Subsystem: Red Hat, Inc Device 1100
        Physical Slot: 5
        Flags: fast devsel, IRQ 10
        Memory at f2022000 (32-bit, non-prefetchable) [size=256]
        Memory at f2023000 (32-bit, non-prefetchable) [size=4K]
        Memory at f2100000 (32-bit, non-prefetchable) [size=1M]
        Capabilities: [40] MSI-X: Enable- Count=1 Masked-
root@kvm0:~# dmesg
# 省略
uio_ivshmem 0000:00:05.0: PCI INT A -> Link[LNKA] -> GSI 10 (level, high) -> IRQ 10
uio_ivshmem 0000:00:05.0: irq 42 for MSI/MSI-X
MSI-X enabled

ivshmem PCIバイスがRAM memoryという名前で認識されていることがわかります。またuio_ivshmemカーネルモジュールがロードされ初期化されています。さらにUIOデバイス用に/dev/uio0というキャラクタデバイスファイルが生成されています。当該デバイスファイルにivshmemツールに含まれるgetidentというプログラムを使うとIDを調べることができます。

root@kvm0:~/tests# ./getident /dev/uio0
ID is 0
exiting
root@kvm1:~/tests# ./getident /dev/uio0
ID is 1
exiting

それぞれ別のIDが振られていることが確認できます。

いよいよ本番です。uio_sendとuio_readというプログラムを動かしてみます。この2つのプログラムはivshmemを介してデータを送信/受信するだけです。

まずkvm0でuio_readを起動します。

root@kvm0:~/tests# ./uio_read
USAGE: uio_read <filename> <count>
root@kvm0:~/tests# ./uio_read /dev/uio0 10
[UIO] opening file /dev/uio0
[UIO] reading

すると、readシステムコールでブロックします。uio_sendからのデータを待っているようです。

次にkvm1でuio_sendを動かします。

root@kvm1:~/tests# ./uio_send
USAGE: uio_ioctl <filename> <count> <cmd> <dest>
root@kvm1:~/tests# ./uio_send /dev/uio0 10 zzz 0
[UIO] opening file /dev/uio0
[UIO] count is 10
[UIO] writing 0
[UIO] ping #0
[UIO] ping #1
[UIO] ping #2
[UIO] ping #3
[UIO] ping #4
[UIO] ping #5
[UIO] ping #6
[UIO] ping #7
[UIO] ping #8
[UIO] ping #9
[UIO] Exiting...

1秒おきにデータを書き込んでいるようです。そうするとuio_readの方も反応します。

#(cont.)
[UIO] buf is 1
[UIO] buf is 2
[UIO] buf is 3
[UIO] buf is 4
[UIO] buf is 5
[UIO] buf is 6
[UIO] buf is 7
[UIO] buf is 8
[UIO] buf is 9
[UIO] buf is 10
[UIO] Exiting...

おそらく、uio_sendの書き込みをkvm0(qemu)が受け取ってivshmem_serverへ通知し、その後ivshmem_serverがそのイベントをkvm1(qemu)へ通知し、kvm1のPCIバイスへ割り込みがかかるという動作なのだと思います(注意: コードをほとんど読んでないので違うかもしれません)。

おわりに

なにはともあれ、ivshmemがちゃんと機能していることが確認できました。

単純なデータの受け渡しやイベント通知ならばネットワークを介しても可能ですが、大きなデータの共有や低オーバヘッドを実現したい場合にはivshmemは役に立つと思います。

内部解析はまた別の機会にやりたいと思います。

*1:shmemを使っているのでゲスト・ホスト間でもデータ共有が可能です。

*2:らしいです。詳しい動作はまだ調べていません。

*3:実際にはVMをブートさせた後にbindマウントしましたが、それでも問題なく動きました。

*4:btrfsであればsnapshotを作るところですが。