彷徨えるフジワラ

年がら年中さまよってます

DTrace の sdt プロバイダ実装を調べる

ふと思いついたカーネルモジュールのアイディアが、DTrace を使えば良い感じに実現できるのではないか?と思って、DTrace の内部実装を調べてみる。

ちなみに、このエントリには有用な情報はおそらく全く含まれていません。単に「カーネルソースを読んでいたら、DTrace の無茶っ振りに燃えた」というだけの話です(笑)。

とりあえず DTrace プロバイダの実装について調べてみよう、ということで、sys/dtrace.h ヘッダファイルを片手に、SPARC 向けの sdt プロバイダ実装をつらつらと眺めてみることに。
sys/dtrace.h が必要なのは、DTrace プロバイダの提供すべき SPI(Service Provider Interface)的な仕様と、DTrace プロバイダ実装が使用する(であろう) API がまとめてあるため。

sdt プロバイダ実装を読むのは、やっていることが非常に明確である点と、実は他の大多数のプロバイダが sdt の別名扱いなので、独自プロバイダの参考になるのは実質的には fbt/sdt プロバイダあたりしか無いため。

わざわざ SAPRC プロセッサ向けの sdt プロバイダ実装を読むのは、素でアセンブラを読んだ時に何をやっているのかピンと来るのは、未だに SPARC プロセッサの方だから(笑)。

うーん、アセンブラ入門記事を書く際に x86 についても勉強したんだけど、やっぱり圧倒的に SPARC の方が使い込んでいるからなぁ。このご時世だと、もう少し x86 に習熟しておかないと路頭に迷ってしまうので、何とかせねば、と思ってはいるのだけど。

で、SPARC 向けの sdt プロバイダ実装では、各プローブごとに「トランポリン(trampoline)コード」と呼ばれる以下のような命令列の埋め込みを sdt_initialize() 関数でやっている。

        *instr++ = SDT_SAVE;

        if (sdp->sdp_id > (uint32_t)SDT_SIMM13_MAX)  {
                *instr++ = SDT_SETHI(sdp->sdp_id, SDT_REG_O0);
                *instr++ = SDT_ORLO(SDT_REG_O0, sdp->sdp_id, SDT_REG_O0);
        } else {
                *instr++ = SDT_ORSIMM13(SDT_REG_G0, sdp->sdp_id, SDT_REG_O0);
        }

        *instr++ = SDT_MOV(SDT_REG_I0, SDT_REG_O1);
        *instr++ = SDT_MOV(SDT_REG_I1, SDT_REG_O2);
        *instr++ = SDT_MOV(SDT_REG_I2, SDT_REG_O3);
        *instr++ = SDT_MOV(SDT_REG_I3, SDT_REG_O4);
        *instr = SDT_CALL(instr, dtrace_probe);
        instr++;
        *instr++ = SDT_MOV(SDT_REG_I4, SDT_REG_O5);

        *instr++ = SDT_RET;
        *instr++ = SDT_RESTORE;

実は埋め込み領域を確保するために、kobj_texthole_alloc() なる「text 領域への隙間確保」を行なう関数があらかじめ用意されているなど、「将来のバイナリパッチに備えて NOP 埋め込み」なんていう苦労は過去のものになってしまったのね、的な感慨も湧いてきたのだけど、とりあえずそれはここでは置いておくということで。

トランポリンコードがやっていることは:

  1. スタックフレームを確保(SDT_SAVE)
  2. 第1引数にプローブ識別用ID(コードで言うところの sdp->sdp_id)を格納
  3. 第1 〜 第5の5つの引数(SDT_REG_I0 〜 SDT_REG_I4)を、次の関数呼び出しの第2〜第6引数(SDT_REG_O1 〜 SDT_REG_O5)に詰め替え
  4. dtrace_probe 関数を呼び出し(SDT_CALL)
  5. スタックフレームを解放して復帰(SDT_RET + SDT_RESTORE)

で、dtrace コマンドによって採取用のプローブが有効になると、DTRACE_PROBE() マクロを記述した箇所に、上記トランポリンコードに飛び込むための call 命令が埋め込まれるので:

  1. プローブ採取箇所からトランポリンコード呼び出し
  2. トランポリンコードから dtrace_probe 関数呼び出し
  3. dtrace_probe 関数で D スクリプトに応じた処理を実施

という流れに。

プローブ採取箇所から dtrace_probe 関数を直接呼び出さないのは、実行すべき D スクリプトの特定にプローブ識別用 ID が必要だからだと思われる。

呼び出し元アドレスから逆引きすればよさそうな気もしないではないけど、hash なり avl tree なりで高速化しないと性能劣化につながることを考えれば妥当な判断なのかな。条件分岐が噛まなければ、パイプラインが詰まる懸念も低いだろうし。

で、ここでハタと気になるのは:

DTRACE_PROBE() マクロに指定した第6引数以降の引数はどうなるの?

ということ。

ユーザ空間向けの DTRACE_PROBE() マクロや、以前の版のカーネル向け DTRACE_PROBE() マクロは、引数が 5 までのものしか定義されていなくて、「あぁ、SPARC 向けの最適範囲に絞って公開しているんだなぁ」などと思っていたのだけど、最新版の sys/sdt.h ヘッダでは、引数 6 個〜8 個用の DTRACE_PROBE() マクロも定義されている。

じゃぁ、dtrace_probe() 関数呼び出しの際に切り捨てられる(様に見える)引数はどうなるの?というあたりで、さぁ、スイッチが入ってきました!

先に述べた DTrace プロバイダの SPI を規定する dtrace_pops 構造体には、dtps_getargval というメンバが定義されていて、名前からも分かるように、プローブの引数(arg0、arg1 等)値を参照する際に、このメンバの保持する関数ポインタが使用される。

しかし、SPARC の sdt プロバイダは、このメンバを NULL 設定していて、それは DTrace のビルトイン実装である dtrace_getarg() を使用することを意味している(と、sys/dtrace.h に説明が書いてある)。

static dtrace_pops_t fbt_pops = {
        NULL,
        fbt_provide_module,
        fbt_enable,
        fbt_disable,
        fbt_suspend,
        fbt_resume,
        fbt_getargdesc,
        NULL, /* dtps_getargval */
        NULL,
        fbt_destroy
};

ここで「DTrace のビルトイン実装」である dtrace_getarg() を、usr/src/uts/common/dtrace 配下で探そうとして途方に暮れてしまった。

ビルトイン=デフォルト=アーキテクチャ独立、的な連想をしてしまったのだけれど、dtrace_getarg() が定義されているのは、アーキテクチャ固有ソースである usr/src/uts/sparc/dtrace/dtrace_isa.c(SPARC の場合)という罠。

SPARC 版の dtrace_getarg() では、思いっきりスタック構造を意識していて、アセンブラ実装も使いつつ、フレームをさかのぼって引数の引き当てを行なっている。まぁ、アーキテクチャ固有コードだから、そんなことはお手の物でしょうよ。

っつーか、どっちもアーキテクチャ固有コードで同じディレクトリ配下にあるんだから、直接呼べよ!という気が。スタック階層の段数も変わらない筈なんだけどなぁ。

一応、性能に関しても配慮されていて:

  • arg0 〜 arg4 = dtrace_probe() 呼び出しで渡される引数の参照は、dtrace_probe() 内部で完結
  • arg5 = トランポリンコードまでは届くけど dtrace_probe() には渡らない引数は、トランポリンコードのスタックフレームから取得
  • arg6 〜 = プローブ呼び出し時点でスタックに積まれているものは、スタックフレームから取得

という感じになっている。

引数渡しが「スタック積み」前提の x86 の場合、DTrace が謳うところの less overhead ってのはイマイチ納得し難いものがあるけど、SPARC の場合は「引数 5 個以内なら、まぁ、そう言わせてやってもいいかな?」的な納得感があるんだよねぇ。