先日の記事「GDBで関数の実行時間を計測する」の関連記事です。こちらの記事では xv6 におけるランタイムオーバーヘッド、すなわち実行時間を計測するために、QEMU + GDB の組み合わせで、任意のブレークポイント間の実行時間をミリ秒単位で取得していました。

今回も目的は同じですが、取り扱うのは別のアプローチです。「xv6 の ticks が時間計測単位として粒度が粗すぎるなら、より細かい CPU クロックサイクル数を取得してしまおう」というお話になります。クロックサイクル数ですので、単位は cycles になります。

クロックサイクル数とは?

CPU には「クロック」と呼ばれる周期が存在します。これは、CPU の各装置(ALU、CU、LU など)がタイミングを合わせて各命令を実行するために用いられるものです。カチ、カチ、カチ…と、1クロックが進むごとに各処理がタイミングを合わせて実行されます。

なお、各 CPU 命令には CPU 内部で複数の手続きが必要になりますので、1命令 = 1クロックというわけではありません。現代の主要な CPU アーキテクチャ、例えば x86 や x86_64 では、多数の手続きを必要とする高機能な CPU 命令があり、例えば VM で用いられる EPT(拡張ページテーブル)を切り替える VMFUNC 命令は数百 cycles を要します。詳細な実装は分かりませんが、この間に複数回のレジスタへの書き込みや TLB のキャッシュクリア、メモリコピーといった処理が内部で行われているものと推察します。

クロックサイクル数は、CPU が起動してから現在までのクロックを刻んだ総数です。この値を取得すると、(前回、GDB でブレークポイント発生時の現在時刻を目安としたように)任意の地点でのクロックサイクル数を目安として、各処理・各関数の実行中にどれだけのクロックが発生したか、すなわちどれだけの時間を要したかが把握できます。

(最近の CPU ではアウトオブオーダー実行で実行順序が変わってしまい正確に測れないという話もありますが、その点については割愛します)

x86, x86_64 におけるクロックサイクル数

x86 ではクロックサイクル数はタイムスタンプと呼ばれ、64 bit の値で表されます。この値はプロセッサの Time Stamp Counter という MSR (Model Specific Register)に保持されています。

MSR というのは、CPU 固有のモデルに合わせてプロセッサに用意されているレジスタで、何らかの CPU の設定や状態を受け取る際に利用するものです。基本的にはこれらのレジスタは特権モード(スーパバイザモード)でのみ読み書きができます。

rdtsc

参考:RDTSC — Read Time-Stamp Counter

rdtsc は、Time Stamp Counter から汎用レジスタに現在のタイムスタンプの値を書き出す CPU 命令です。

書き出し先は EDX:EAX です。EDXEAX はそれぞれ 32 bit レジスタですので、64 bit のタイムスタンプの値のうち、上位 32bit が EDX に、下位 32 bit が EAX に書き出されます。

この命令はユーザモードでも実行可能なようですが、特権モードのみに制限したい場合は CR4 レジスタの Time Stamp Disable (CR4.TSD) フラグを 1 にセットしておけばいいようです。

Linux における実装

Linux の場合、x86intrin.h_rdtsc() という形で RDTSC 命令の呼び出しが実装されているようです。

早速使ってみます。

#include <stdio.h>
#include "x86intrin.h"

int main()
{
        unsigned long long now_tsc = _rdtsc();
        printf("tsc: %llu cycles\n", now_tsc);

        return 0;
}

rdtsc_base という名前でコンパイルし、複数回実行してみます。

$ ./rdtsc_base
tsc: 1572159445649225 cycles
$ ./rdtsc_base
tsc: 1572164024735574 cycles
$ ./rdtsc_base
tsc: 1572165714475315 cycles
$ ./rdtsc_base
tsc: 1572167368930875 cycles

なるほど、実行のたびにカウントアップされていく様子がわかりますね。

次にランタイムオーバーヘッドを計測してみます。

#include <stdio.h>
#include "x86intrin.h"

int main()
{
        unsigned long long start = _rdtsc();
        // do something
        for (int i=0; i<100; i++) {
        }
        unsigned long long end = _rdtsc();
        printf("tsc: %llu cycles\n", end - start);

        return 0;
}

とりあえず for 文で 100 回ループするときの実行時間を計測させてみました。結果がこちら。

$ ./rdtsc_runtime_overhead
tsc: 239 cycles

xv6 における実装

QEMU でも RDTSC はサポートされていますので、CPU 命令として実行が可能です。

Linux では _rdtsc() が実装されていましたが、もちろん xv6 には実装されていませんので自分で実装します。アセンブリで CPU 命令を直接呼び出しましょう。

下記の関数を x86.h に実装します。

static inline uint64 rdtsc() {
    uint64 ret1;
    uint64 ret2;
    asm volatile("rdtsc" : "=a"(ret1), "=d"(ret2));
    uint64 ret = (ret2 << 32) | ret1;
    return ret;
}

uint64unsigned long long に該当します。

ここでやっていることとしては、

  • rdtsc 命令をアセンブリで呼び出す。
  • 実行後、RAX の結果を ret1 に、RDX の結果を ret2 に格納する。
  • ret2 を 32 bit 分ビットシフトし、ret1 の値との論理和を求める。最終的に 64 bit の値になる。

これで 64 bit のタイムスタンプの値が取得できます。

#include "types.h"
#include "user.h"
#include "x86.h"

int main()
{
    uint64 tsc_start = rdtsc();
	
    // do something
    for (int i=0; i<100; i++) {
    }

    uint64 tsc_end = rdtsc();
    printf(1, "runtime overhead: %d cycles\n", tsc_end - tsc_start);

    exit();
}
$ rdtsc
runtime overhead: 35970 cycles

以上です。

参考文献