C/C++プログラマのためのDTrace入門

第8回DTrace併用時の性能劣化

DTrace は「オーバヘッドが低い⁠⁠、場合によっては「オーバヘッドが殆ど無い」といった表現をされることがありますが、付加的な処理=情報採取を行うにあたり、オーバヘッドが無いということは有り得ません。

ただし、DTraceの動作原理等を踏まえた上で適切な使い方をすることで、余分なオーバヘッドを抑止することはできます。

そこで今回は、DTraceを利用する際の性能劣化に着目して、どのような点に留意すべきかを説明したいと思います。

アセンブラレベルから見た独自プロバイダのオーバヘッド

独自プロバイダの実現方式

独自プロバイダの実現方式について説明するに当たって、第6回で説明したリンク時手順を振り返ってみましょう。

独自プロバイダのプローブ呼び出しを行うソースをコンパイルした直後、つまりオブジェクトファイルを生成した時点では、プローブ呼び出し部分は通常の関数呼び出しと同様に、引数準備を行ったうえでプローブに相当する関数の呼び出しを行っています。32ビットx86環境で生成されるオブジェクトファイルの逆アセンブル結果をリスト1に示します(※1⁠⁠。

リスト 1 プローブ呼び出しのアセンブラコード
    subl   $0x8,%esp  ; スタックフレーム拡張
    pushl  $0xa       ; 第2引数(4バイト)の準備
    pushl  $0x0       ; 第1引数(4バイト)の準備
    call   __dtrace_checkpoint___pass
    addl   $0x10,%esp ; 引数領域の解放

このオブジェクトファイルに対して、"dtrace -G" 実行によるリンク前処理を行うことで、リスト 2 のようにプローブ呼び出し処理が除去されます。

リスト 2 前処理後のアセンブラコード
    subl   $0x8,%esp  ; スタックフレーム拡張
    pushl  $0xa       ; 第2引数(4バイト)の準備
    pushl  $0x0       ; 第1引数(4バイト)の準備
    nop
    nop
    nop
    nop
    nop
    addl   $0x10,%esp ; 引数領域の解放

"call __dtrace_checkpoint___pass"に必要な5バイトの領域が、全てnopによって置き換えられているのがわかります。

この前処理によって、プローブ呼び出しそのものは実施されなくなりますが、プローブ呼び出しに必要な命令を埋め込む領域はnop によって確保されたままです。つまり、プローブ呼び出しを有効化したくなった際には、この領域にプローブ呼び出しを行う命令を埋め込むことで、プローブの活性をプログラム実行中に動的に変更できるわけです。

第3回での pidプロバイダの実現方式確認の手順を利用すれば、DTrace 適用時の上記挙動を確認することができます。

プローブ無効化方式に関する考察

先述したように、"dtrace -G"実行によるリンク前処理によって、⁠プローブ呼び出し」に相当する「"__dtrace_*"関数の呼び出し」は除去されますが、前処理前後の逆アセンブル結果を比較すればわかるように、プローブ呼び出しに伴う引数準備処理は残されたままです。

つまり、独自プロバイダのプローブ呼び出しが埋め込まれているプログラムは、プローブ呼び出しをしない場合と比較すれば、当然いくらかのオーバヘッドが上積みされているわけです。

そのため、以下のように考える方がいるかもしれません。

プローブ活性に応じて条件分岐させることで、プローブ呼び出し処理(引数準備含む)を抑止した方が、性能劣化が少ないのでは?

C 言語的に書くならば、リスト3のようなイメージでしょうか?

リスト3 プローブ無効化案(1)
/* プローブ有効時には probe_enabled を 1 に書き換え */
if(probe_enabled){
    PROVIDER_PROBE(arg0, arg1, arg2, ....);
}
....

しかし、このような条件分岐を行った場合、命令実行パイプラインの乱れが発生してしまうため、条件分岐によって節約される命令実行コストと、条件分岐命令そのものによって増加するコストには、期待される程には差が生じません。

そうなると、今度は以下のように考える方も居ることでしょう。

プローブ活性に応じて無条件分岐命令を埋め込むことで、パイプラインを乱れさせることなくプローブ呼び出し処理を抑止できるのでは?

C言語的に書くならば、リスト4のようなイメージでしょうか?

リスト4 プローブ無効化案(2)
    /* プローブ有効時には goto 文を nop に書き換え */
    goto probe_disabled;
    PROVIDER_PROBE(arg0, arg1, arg2, ....);
  probe_disabled:
    ....

実行効率の点から見た場合、この方法はなかなか良さそうに思えますが、実は大きな問題があります。それはコンパイラの最適化処理等を考慮する必要があることです。

今時のコンパイラの最適化処理に掛かると、必ずしもプログラム中の各ステートメントと綺麗に対応関係の取れたアセンブラコードが生成されるわけではありません。あるステートメントを無効化するつもりで無条件分岐命令を埋め込んだ場合に、前後のステートメントに関連する部分まで無効化してしまう可能性もゼロではありません。

また、事前に(=コンパイル段階で)無条件分岐命令が埋め込まれるようにすると、最適化レベルによっては、プローブ呼び出し部分がコンパイルの際に除外されてしまう可能性もあります。

以上のことから、コンパイラとは独立した実現を前提とした場合、現状の独自プロバイダの実現方式は、最適とは言えないものの妥当な実現方式ではあると言えるでしょう。

プローブ埋め込みによるオーバヘッドの計測

では実際のところ、DTraceのプローブ埋め込みにより、どの程度のオーバヘッドが生じるのでしょうか?

単純に実行時間で比較したのでは、計測環境におけるCPUクロックの高低による影響があることから、以下の手順で遅延クロック数を算出することにしました。

  1. 独自プローブに渡す引数は6つ
  2. プローブ非活性状態で、引数準備処理だけを1012回繰り返す
  3. アセンブラレベルで引数準備処理を除外したものを同じように1012回繰り返す
  4. 実行時間の差分を取る
  5. ループ1回あたりの性能劣化をクロック数に変換

以下の2つのCPUで計測しました。

  • UltraSPARC IIIi(1.0GHz)
  • AMD PhenomII X4 905e(2.5GHz)

計測結果を以下に示します。

表1 プローブ埋め込みによる遅延の計測
環境性能低下
(単位: clocks/loop)
引数渡しの方式
SPARC2.0レジスタ
x86(32bit)6.0スタック
x86(64bit)0.1レジスタ

SPARC アーキテクチャや 64bit x86(AMD64)における性能劣化が小さいのは、引数渡しにレジスタを使用する ABI(Application Binary Interface)である(※2)ことから、使用頻度の高い(=レジスタに乗り易い)局所変数値の引数渡しであれば、レジスタ間コピーのみのオーバヘッドで済むためだと思われます。

6つの引数を積むオーバヘッドがそれぞれ2.0と0.1(!)ですから、スーパースケーラ(superscalar)アーキテクチャの面目躍如といったところでしょうか?

その一方で、32bit x86アーキテクチャの性能劣化が大きいのは、引数渡しにスタックを使用する仕様であるため、局所変数参照でもスタック(=メモリ)からの書き込みが発生するためでしょう。

(CPU クロックと比較して)低速なメモリへのアクセスがあるにも関わらず、概ね引数の数と同程度のクロックの遅延で済んでいるのは、おそらくスタック領域がデータキャッシュに乗っていることによるものだと思われます。

この結果を見る限りでは、64ビットx86においてなら(SPARCも?)⁠DTrace無効時は、プローブ埋め込みによるオーバヘッドはほぼゼロ」と言っても差し支え無いかも?という印象ですね。

なおここでの計測結果は、あくまで(単純化した)特定のモデルにおける性能劣化の計測値であって、常に同等の計測結果が得られるわけではありませんからご注意ください。

プロバイダ有効時の留意事項

pidプロバイダによる性能劣化

これまでにも何度か触れてきましたが、DTraceは指定されたDスクリプトをカーネル空間で実行しています。

記述された処理の実行が必要になる都度、カーネルへのコンテキストスイッチが発生してしまうことから、通常の関数呼び出しと比較して格段に大きなオーバヘッドを伴います。

そのため、たとえばpidプロバイダを使用して、全ての関数の呼び出し・復帰を採取するようなDスクリプトを適用した場合、各関数の冒頭/末尾で毎回システムコールを発行するようなものですから、DTraceを併用しない通常の実行に比べれば、対象プログラムの実行性能は大きく低下することでしょう(※3)。

Dスクリプトにおいて処理の実施を制限する方法としては、まず第一に「対象関数を明示して絞り込む」方法があります。

たとえばリスト5のように記述した場合、command 中の全ての関数の呼び出しを契機にしてカーネルへのコンテキストスイッチが発生します。

リスト5 全関数がコンテキストスイッチ契機
pid$target:command::entry
{ .... }

その一方でリスト6のように記述した場合、カーネルへのコンテキストスイッチが発生しするのは関数funcAの呼び出しだけに限定されるため、リスト5の記述と比較してオーバヘッドを大幅に低減させることができます。

リスト6 指定関数のみがコンテキストスイッチ契機
pid$target:command:funcA:entry
{ .... }

Dスクリプトにおいて処理の実施を制限するもうひとつの方法として、⁠述語(前提条件)を記述する」方法があります。

たとえば、リスト7のように記述することで、"printf()"アクションの実施は、"self->traced"が成立している場合、つまり関数"doit()"の開始から終了の間に限定されます。

リスト7 述語記述による絞込み
pid$target:$1:doit:entry
{ self->traced = 1; }

pid$target:$1::entry,
pid$target:$1::return
/self->traced/
{ printf("%s():%s", probefunc, probename); }

pid$target:$1:doit:return
{ self->traced = 0; }

"printf()"アクションの実施契機が減少することから、この方法も一見すると効率が良いように見えますが、実際のDスクリプトの実行では:

  1. 全ての関数の冒頭でいったんカーネルにコンテキストスイッチ
  2. 条件 "self->traced" が成立しているかを判定
  3. 条件が成立している場合はアクションを実行

という手順が踏まれるため、述語記述による「アクション実行」の絞込みは「カーネルへのコンテキストスイッチ」という大きなオーバヘッドの削減には効果がありません。

もしも性能劣化を極力回避したい状況で、アクションの実行対象となる関数が絞り込めるのであれば、明示的に対象関数を列挙することをお勧めします。

独自プロバイダ有効時の性能劣化抑止

独自プロバイダ定義からヘッダファイルを生成("dtrace -h"実行)すると、リスト8に示すようなマクロ定義が含まれているはずですcheckpointプロバイダのpassプローブの例⁠⁠。

リスト8 活性判定マクロ定義
#define CHECKPOINT_PASS_ENABLED() \
        __dtraceenabled_checkpoint___pass()

マクロ名は「プロバイダ名」「プローブ名」ENABLEDという形式で構成されます。

このマクロはプローブ呼び出しに使用する引数の準備が非常にコスト高な場合に、プローブ活性の有無に応じてプローブ呼び出し=引数の準備を抑止するのに使用します。

リスト9 活性判定マクロの使用例
if(XXXX_YYYY_ENABLED()){
    XXXX_YYYY(calc_complex_value());
}

たとえば、リスト9 のXXXX_YYYYプローブ呼び出しにおいて、第1引数を算出するcalc_complex_value()関数が非常にコスト高だとしましょう。

先に述べたように、プローブが無効化されていても引数準備処理は実施されます。

そのため、"XXXX_YYYY_ENABLED()"判定を使用しない場合、プローブ呼び出しの要否に関わらず、コスト高なcalc_complex_value()関数が実行されてしまうことから、実行性能は全般的に低下してしまいます。

その一方で、プローブ埋め込みの際にリスト9のような実装を行った場合、プローブが無効化されている=情報採取の必要性が無ければ、プローブ呼び出しに関わる全ての処理が回避されますので、情報採取をしない場合の実行時性能劣化を最小限に抑えることができます。

なお、プローブの有効化が関数単位でできるのと同様に、プローブの活性も各関数ごとに個別に判定することができます。

プローブ引数に関する留意事項

DTraceのsdtプローブの定義(※4)に関する説明には、プローブ呼び出しを行う際の引数に関する注意として、以下のように記述されています。

ポインタを間接参照せず、プローブ引数内の大域変数からロードしないようにすれば、無効時のプローブの影響を最小限に抑えることができます。ポインタの間接参照も、大域変数のロードも、Dのプローブ有効化アクション内で安全に実行できます。

少々言い回しが堅苦しいですが、プローブ呼び出しの引数に指定する値には、ポインタからの間接参照値や大域変数参照値を指定するなということです。

それでは、なぜ「ポインタからの間接参照値や大域変数参照値」を指定することが良くないのでしょうか?

間接参照や大域変数参照の際には、必ずメモリへのアクセスが発生します。そうすると、以下のような事象の発生する可能性が出てくるのです。

メジャーフォルト(major fault)の発生:
メジャーフォルトとは、アクセス先の仮想アドレスに対応するページが、物理メモリ上にない状態を指します。
ディスク領域(=スワップデバイス)に退避されている内容を物理メモリへと復旧させる作業が、カーネルによって実施されます
マイナーフォルト(minor fault)の発生:
マイナーフォルトとは、アクセス先の仮想アドレスに対応するページは物理メモリ上にはあるものの、対応する仮想アドレス~物理アドレス変換を行うための変換エントリが、MMU(Memory Management Unit)の管理領域に無い状態を指します。
仮想空間の管理テーブルからMMUへの変換エントリ充填作業がカーネルによって実施されます。

これらは共に、システムコール呼び出しのオーバヘッドなどとは比較にならないほどの処理コストを必要としますので、回避できるのであれば極力回避したい事象です。

先述した動作原理の都合から、独自プロバイダのプローブが無効化されていても、呼び出し引数の準備処理だけは実行されてしまいます。そのため、引数準備のための処理を契機として、上記の様なメジャー/マイナーフォルトが発生してしまう場合、プローブ呼び出しを追加したことで、プローブが無効化されていてもオーバヘッドが上乗せされてしまうわけです。

それでは、ポインタの間接参照値や大域変数参照値を使用したい場合はどうすればよいのでしょうか?

先に引用した文章では、⁠必要に応じてDスクリプトの中でアクセスすればよい」と述べられています。

Dスクリプトの中でアクセスしたからといって、メジャー/マイナーフォルトの発生が抑止されるわけではありません(※5)が、⁠情報採取しない場合のオーバヘッドは避けたいが、情報入手が必要な場合にコストを要するのは仕方がない⁠ という考え方だと言えるでしょう。

ちなみに、カーネルのロードモジュール中の大域変数に関しては、参照する大域変数名の冒頭にバッククォート("`")を付けることで参照可能なのですが、ユーザプログラム中の大域変数に関しては、残念ながらこの方法でアクセスすることができません(※6⁠⁠。

しかし、以下に説明するような一手間をかけてやることで、ユーザプログラム中の大域変数を参照できるようになります。

リスト10のDスクリプトは、対象コマンド名$1を置換)に加えて、参照対象となる変数のアドレスが指定$2を置換)されることを前提としています。変数シンボルからアドレス値を得るのではなく、アドレス値を直接指定するわけです。

リスト10 大域変数の参照watch_globalvar.d
self int* buf;

pid$target:$1::entry
{
    self->buf = alloca(sizeof(int));
    copyinto($2, sizeof(int), self->buf);
    printf("%d", *(self->buf));
}

それでは、実際に使用する際の要領を図1に示します。

図1 大域変数の参照
$ nm 対象コマンド | grep globalvar
08060fe0 B globalvar
$ dtrace -s watch_globalvar.d \
        -c '対象コマンド ....' \
        対象コマンド \
        0x08060fe0
....

nmコマンド等を使用して、対象となる大域変数(この例では"globalvar")が配置されるアドレスを確認したなら、そのアドレスを使用してリスト10のDスクリプトを実行するわけです。

なお、文字列に関する大域変数を参照する場合は、変数の定義方式によって扱い方が異なります。

リスト11 文字列変数定義(1)
char string[] = "string;

大域変数stringがリスト11に類する形式で定義されている場合、この変数のアドレスは文字列データ格納領域の先頭アドレスそのものですから、stringのアドレスに対してcopyinstr()サブルーチンを適用することで、文字列データを読み込むことができます(リスト12⁠⁠。

リスト12 文字列大域変数の参照(1)
pid$target:$1:main:entry
{
    printf("string=%s", copyinstr($2));
}

その一方で、大域変数stringがリスト13の形式で定義されている場合、この方法では文字列を参照することができません。

リスト13 文字列変数定義(2)
char* string = "string";

この場合の大域変数stringのアドレスは、文字列格納領域を指すポインタが格納されているアドレスですので、リスト14に示すDスクリプトのように、いったんアドレス情報をカーネル空間にコピーして、改めてそのアドレスからcopyinstr()サブルーチンを使用して、文字列データを読み込む必要があります。

リスト14 文字列大域変数の参照(2)
self uintptr_t* buf;

pid$target:$1:main:entry
{
    self->buf = alloca(sizeof(uintptr_t));
    copyinto($2, sizeof(uintptr_t), self->buf);
    printf("string=%s", copyinstr(*(self->buf)));
}

リスト14のDスクリプトを使用する場合には、アプリケーション側で想定しているアドレスビット幅と、カーネル側でのそれを一致させる必要がありますので注意してください。詳細は、第7回での間接参照先の情報取得に関する説明を参照してください。

"char*"として参照に利用する分には、C/C++プログラム上ではどちらの変数も同じ様に扱うことができますが、両者の厳密な意味合いは全く異なるわけです。

おわりに

ユーザプログラムに対するDTraceの適用に関して、8回に渡って説明してきましたが、如何でしたでしょうか?

DTraceは本連載で紹介した機能以外にも、たとえばタイマーによる等間隔処理といった機能も提供していますので、ぜひSunが提供するドキュメントで詳細をご確認ください。

本連載が、DTrace利用の幅を広げるきっかけとしてお役に立てば幸いです。

おすすめ記事

記事・ニュース一覧