checkpoint
まずは、「単純値」を採取するための独自プロバイダを定義してみましょう。
プログラム中の要所要所(但し、関数境界以外) の通過契機を知るための pass プローブを持つ checkpoint プロバイダを定義します (checkpoint.d ファイル)。
pass
checkpoint.d
provider checkpoint { pass(const char*, int); };
※ 通常の D スクリプトと違い、 ブロック末尾にセミコロンが必要なので注意!
プロバイダ定義を元に、 dtrace コマンドでヘッダファイルを生成します。
dtrace
$ dtrace -h -s checkpoint.d
生成された checkpoint.h には、 以下の様なマクロが定義されています。
checkpoint.h
#define CHECKPOINT_PASS(arg0, arg1) \ __dtrace_checkpoint___pass(arg0, arg1) : : extern void __dtrace_checkpoint___pass(char *, int);
マクロ名称は「プロバイダ名」+「プローブ名」で構成されます。
ヘッダ定義におけるプローブマクロの引数は、 プロバイダ定義における記述そのままなので、 利便性を高めるために一手間加えたマクロを定義します (checkpoint_impl.h ファイル)。
checkpoint_impl.h
#define DTRACE_CHECKPOINT_PASS() \ CHECKPOINT_PASS(__FILE__, __LINE__)
これにより、 ファイル名と行番号が自動的に埋め込まれます。
採取対象プログラムとして、 以下の様な実装を想定します (branch_by_arg.c ファイル)。
branch_by_arg.c
if(argc < 2){ DTRACE_CHECKPOINT_PASS(); } else{ int val = atol(argv[1]); DTRACE_CHECKPOINT_PASS(); if(val < 10){ DTRACE_CHECKPOINT_PASS(); } else{ DTRACE_CHECKPOINT_PASS(); } } DTRACE_CHECKPOINT_PASS();
採取対象プログラムのソース branch_by_arg.c を、 以下の要領でコンパイルします。
$ cc -c branch_by_arg.c
生成された branch_by_arg.o を普通にリンクすると、 以下の様なエラーが発生します。
branch_by_arg.o
$ cc -o branch_by_arg branch_by_arg.o Undefined first referenced symbol in file __dtrace_checkpoint___pass branch_by_arg.o ld: fatal: symbol referencing errors. No output written to branch_by_arg
独自定義のプローブを使用するプログラムは、 リンクに先立っての前処理が必要です。
$ dtrace -G -s checkpoint.d branch_by_arg.o
dtrace コマンドによる前処理により、 以下のような処置が実施されます。
checkpoint.o
dtrace コマンドが生成した checkpoint.o と一緒にリンクを行ってください。
$ cc -o branch_by_arg branch_by_arg.o checkpoint.o
D スクリプト watch_checkpoint.dにより、 checkpoint プローブから、 ファイル名/関数名/行番号が取得できます。
watch_checkpoint.d
checkpoint$target:$1::pass { printf("%s:%d:%s\n", copyinstr(arg0), arg1, probefunc); }
userio
独自プロバイダを使用して、 可変長のメモリ領域の内容を採取してみましょう。
なお、ここで使用する手法は、 関数呼び出しにおける引数参照先のメモリ内容を採取する場合にも適用できます。
内部的な読み書き処理契機とその内容を知るためのプローブを持つ userio プロバイダを定義します (userio.d ファイル)。
userio.d
provider userio { probe readin(const char* filename, int lineno, void* buf, size_t len); probe writeout(const char* filename, int lineno, void* buf, size_t len); };
※ プログラムのリンクまでは、 「単純値の採取」と同じ手順なので省略します
固定長でのメモリ内容採取 (「ユーザプログラムでの DTrace 〜 導入編」参照のこと)は、 D スクリプト watch_args_mem.d で実施しました。
watch_args_mem.d
pid$target:show_args:checksum:entry { this->iobuf = alloca(32); copyinto(arg0, 32, this->iobuf); tracemem(this->iobuf, 32); }
ここでの問題は、 tracemem が固定長しか扱えないことです
ここでの問題は、 tracemem が固定長しか扱えないことです → 現実的な採取を考えれば、 可変長採取であってもメガバイト単位の採取は有り得ません
ここでの問題は、 tracemem が固定長しか扱えないことです → 現実的な採取を考えれば、 可変長採取であってもメガバイト単位の採取は有り得ません → キロバイト単位でもせいぜい 1K 〜 2K が良いところでは?
ここでの問題は、 tracemem が固定長しか扱えないことです → 現実的な採取を考えれば、 可変長採取であってもメガバイト単位の採取は有り得ない → キロバイト単位でもせいぜい 1K 〜 2K が良いところでは? → 固定長のメモリ内容表示を組み合わせれば良い
可変長領域の内容表示は、D スクリプト watch_io.d の要領で実現可能です。
watch_io.d
inline int width = 128; userio$target:read_n_write:: { self->offset = - width; } userio$target:read_n_write:: /(self->offset += width) < arg3/ { self->buf = alloca(width); copyinto(arg2 + self->offset, width, self->buf); tracemem(self->buf, width); } userio$target:read_n_write:: /(self->offset += width) < arg3/ { .... 上記節と同内容 .... } .... 以下省略 ....
節の実行が順次実行である点を利用して、 self->offset の更新を行います。
self->offset
self->offset の初期値を "- width" にすることで、 0 offset 表示の節も含めて、 メモリ内容表示のための節は全て同一定義にできます
- width
self->offset の初期値を "- width" にすることで、 0 offset 表示の節も含めて、 メモリ内容表示のための節は全て同一定義にできます → 最大表示範囲の分だけ節定義をコピー&ペースト
D スクリプト watch_io.d を見て、 以下のように思われるかもしれません。
最初の節で self->buf = alloca(width) した領域にまとめて copyinto() して、 それを順次 tracemem() すれば良いのでは?
self->buf = alloca(width)
copyinto()
tracemem()
C/C++ での alloca() が関数に局所的なのと同様に、 DTrace の alloca() も節に局所的なため、 節の間で alloca() 領域を共有することはできません。
alloca()
alloca() は、スクラッチ空間からsize バイトを割り当て、 ....[snip].... スクラッチ空間は、節の開始から完了までの間しか有効ではありません。 alloca() で割り当てられたメモリーは、節の完了時に割り当て解除されます。 〜 「10. アクションとサブルーチン」より
ページサイズの都合上簡略化していますが、 ユーザ空間からの copyin() におけるサイズ指定は、 安全性の点で以下のようにすべきです。
copyin()
copyinto(arg2 + self->offset, (arg3 - self->offset < width ? arg3 - self->offset : width ※ 残量が width よりも大きい場合のみ ), self->buf);
メモリページ末尾の領域を参照している場合、 ページ超えのアクセスはメモリフォルトを発生させてしまいます。
argv[]
間接参照先の内容を知りたいケースの最も典型的な例は、 main() の argv と言えます。
main()
argv
int main(int argc, const char* argv[]) { ........ }
argv の参照先は char* の配列なので、 pid::main:entry プローブの arg1 の参照先をカーネル空間に copyinto() しても、 肝心の文字列は参照できません。
char*
pid::main:entry
arg1
argv の内容=文字列アドレスを取り込んだ上で、 更に参照先=文字列を取り込む必要があります
間接参照先内容の表示は、 D スクリプト watch_indirect.d の要領で実現できます。
watch_indirect.d
self uintptr_t* argv; pid$target:$1:main:entry { self->index = 0; self->argv = alloca(sizeof(uintptr_t*)); copyinto(arg1 + (self->index * sizeof(uintptr_t*)), sizeof(uintptr_t*), self->argv); printf("argv[%d]='%s'", self->index, copyinstr(self->argv[0])); } pid$target:$1:main:entry /(self->index += 1) < arg0/ { self->argv = alloca(sizeof(uintptr_t*)); .... 上記節と同内容 .... } .... 以下省略 ....
self uintptr_t* argv; ※ "self->argv[0]" 参照用の型宣言 pid$target:$1:main:entry { self->index = 0; self->argv = alloca(sizeof(uintptr_t*)); copyinto(arg1 + (self->index * sizeof(uintptr_t*)), sizeof(uintptr_t*), self->argv); printf("argv[%d]='%s'", self->index, copyinstr(self->argv[0])); } pid$target:$1:main:entry /(self->index += 1) < arg0/ { self->argv = alloca(sizeof(uintptr_t*)); .... 上記節と同内容 .... } .... 以下省略 ....
self uintptr_t* argv; pid$target:$1:main:entry ※ 0 < arg0 は保証済み { self->index = 0; self->argv = alloca(sizeof(uintptr_t*)); copyinto(arg1 + (self->index * sizeof(uintptr_t*)), sizeof(uintptr_t*), self->argv); printf("argv[%d]='%s'", self->index, copyinstr(self->argv[0])); } pid$target:$1:main:entry /(self->index += 1) < arg0/ { self->argv = alloca(sizeof(uintptr_t*)); .... 上記節と同内容 .... } .... 以下省略 ....
argv[] 採取の実施は以下の要領で行います。
$ dtrace -s watch_argv.d \ -c 'echo hoge hage hige huge' \ -32 \ echo
ポインタ値のビット幅=sizeof(uintptr_t)は、 ISA(Instruction Set Architecture) のビット値によって異なります。
sizeof(uintptr_t)
実行に先立って、 file コマンド等により、 アプリケーションが 32bit/64bit のいずれであるかを確認してください。
file
32bit OS 上で 32bit アプリ版の仮想化プログラム (VMWare/VirtualBox 等)を使用していても、 64bit CPU 上で稼動する仮想マシンでは 64bit カーネルが稼動している可能性があるので、 "isa -b" 等で確認しましょう。
isa -b
D スクリプト解釈におけるアドレスビット幅は、 カーネルの ISA ビット値がデフォルトとなります。
*_ENABLED()
DTrace による情報採取における実行効率に関するトピックを取り上げます。
dtrace コマンドが生成するヘッダファイルには、 以下の形式の名前を持つマクロが定義されています。
ProviderName_ProbeName_ENABLED()
プローブ呼び出しに使用するデータの準備コストが高い場合、 このマクロでプローブ活性の有無を判定することで、 実行時コストを低減させることができます。
if(XXXX_YYYY_ENABLED()){ XXXX_YYYY(calc_complex_value()); }
独自プローブの活性判定は、 "all(全て有効) or nothing(全て無効)" ではなく、 関数単位で判定することができます。
「第22章 sdtプロバイダ:SDT プローブの作成」曰く:
SDT プローブを宣言するときは、ポインタを間接参照せず、 プローブ引数内の大域変数からロードしないようにすれば、 無効時のプローブの影響を最小限に抑えることができます。 ポインタの間接参照も、大域変数のロードも、 Dのプローブ有効化アクション内で安全に実行できます。
「間接参照」しないことには、以下のメリットがあります。
D スクリプトはカーネル内部で動作するため、 一旦カーネル空間へのコンテキストスイッチが実施されます。
関数呼び出しの都度 D スクリプトが実施されるため、 pid プロバイダによる関数フロー採取は、 少なくないオーバヘッドを伴います。
pid
「関数名による絞り込み」は、 D スクリプトの実行契機を限定しますので、 オーバヘッドを軽減する効果があります。
一方で「述語(前提条件)による絞り込み」は、 判定処理がカーネル空間で実施されるため、 オーバヘッドを軽減する効果は高くありません。
ユーザ定義プローブの無効化は、 採取処理の呼び出し命令 (x86/SPARC 共に call 命令) だけを nop 化することで実現されるため、 プローブが無効化されても引数準備処理だけは残ります。
call
nop
プローブ活性に応じて条件分岐させることで、 プローブ呼び出し処理(引数準備等)を抑止した方が、 実行効率が高いのでは?
スーパースケーラ(super scala)アーキテクチャの CPU では、 条件分岐命令により命令実行パイプライン(pipeline)が乱れるため、 意外に効率が改善しません(しない筈です)。
パイプラインペナルティが高いので、 プローブの無効化を条件分岐で行うのは適切ではありません。
プローブ活性に応じて無条件分岐命令を埋め込むことで、 プローブ呼び出し処理(引数準備等)を抑止した方が、 実行効率が高いのでは?
コンパイラの最適化等を考慮したテキスト改変が必要になります。
例えば、コンパイラが以下の様な判断を行った場合:
事後処理として、 単純に「プローブ呼び出し準備」を抑止してしまうと、 レジスタ A → B 間の退避処理も抑止されてしまうため、 期待する値が設定されていないレジスタ B を元にした引数を用いた関数 X 呼び出しは、 不正な挙動となってしまいます。
また、 コンパイル段階で無条件分岐命令が埋め込まれている場合、 最適化強度が高いケースでは、 無条件分岐命令に関する処理は、 テキスト中から完全に除外される可能性も高いです。
SPARC アーキテクチャの場合:
一方で、x86(32bit/64bit) アーキテクチャの場合:
以上のことから、 SPARC 程には「little overhead」では無いのは確かです。
但し、x86 の内部アーキテクチャ的には:
ではないかなぁ、と期待しています > Intel/AMD
x86 アーキテクチャにおいてこれらの効率化が実現されていないとすると、 「クロックあたりの命令実行効率は SPARC(or RISC アーキテクチャ)の方が圧倒的に良い」という、 Intel/AMD にとっては非常に面白くない結論になるので、 営業戦略上の点からも対策が打たれていない筈は無いでしょう(無いと良いなぁ)。
※ 左右のカーソルキーでもページ繰りができます