Ruby: eBPFでメモリアロケーションをプロファイリングする(翻訳)

概要 原著者の許可および指示により「非公式の翻訳として」翻訳・公開いたします。 英語記事: Spying on a Ruby process’s memory allocations with eBPF - Julia Evans 原文公開日: 2018/01/31 著者: Julia Evans なお、eBPFはBPF(Berkeley Packet Filter)の拡張版(extended BPF)です。 参考: LinuxのBPF : (1) パケットフィルタ - 睡分不足 参考: LinuxのBPF : (3) eBPFの基礎 - 睡分不足 Ruby: eBPFでメモリアロケーションをプロファイリングする(翻訳) 今回は、CPUプロファイラを使う代わりに、まったく新しいアイデアに基づいた実験を披露いたします。 私がその日の朝に思いついたのは、Rubyの任意のプロセスのPID(既に実行されているプロセスのPID)を取得してメモリアロケーションを追えるのではないかというものでした。 ネタバレ: 一応動きました!その様子をasciinemaのデモでご覧いただけます。ここでは、rubocopのメモリアロケーションをクラスごとにカウントしたものを15秒間に渡って刻々と累積的に表示しています。rubocopが数千個ものArrayやStringやRange、そして若干のEnumeratorなどをアロケーションしている様子がわかります。 このデモは、rubocopのコードをまったく変更せずに実行しています。起動も普通のbundle exec rubocopで行いました。デモのコードはすべてhttps://github.com/jvns/ruby-mem-watcher-demoにあります(非常に実験的なコードなのでもしかすると今のところ私のPCでしか動かないかもしれませんが)。 しくみ(その1): eBPF + uprobes 基本的な動作の仕組みは比較的シンプルです。Linux 4.4以降ではuprobesと呼ばれる機能を利用可能です。これは自分の書いたコードをユーザー空間上の任意の関数にアタッチするもので、プロセスの外部から行えます。プログラムの実行中に対象となる関数の変更をカーネルに依頼し、関数が呼び出されるたびに自分のコードを実行します。 ただしカーネルに任意のコードの実行を依頼することは(少なくともeBPFがなければ)普通はできません。カーネルに依頼するのは「eBPFバイトコード」の実行であり、これは基本的にアクセス可能なメモリ領域を制限されているCコードです。かつ、ループは使えません。 私のアイデアは、rubocop内でRubyの新しいオブジェクトが作成されるたびにごく小さなコード片を実行し、そのコードでクラスごとのメモリアロケーションを数えるというものです。 以下は、instrumentationの対象にする(uprobeの追加対象となる)関数であるnewobj_slowpathです。 static inline VALUE newobj_slowpath(VALUE klass, VALUE flags, VALUE v1, VALUE v2, VALUE v3, rb_objspace_t *objspace, int wb_protected) この関数の目的は、最初の引数をその関数(klass)に渡してklassごとのアロケーション数をカウントすることです。 最初のbccプログラムを書く bcc(BPF compiler collectionの略)は以下の作業を支援するツールキットです。 BPFプログラムをCで記述する BPFプログラムをコンパイルしてBPFバイトコードを生成する コンパイル済みBPFバイトコードをカーネルに挿入する カーネル内で実行されるBPFバイトコードとやりとりしてバイトコードからの情報を使いやすく表示するPythonプログラムを記述する 理解すべき点はたくさんありますが、ありがたいことにドキュメントが割りと充実していて、コピーできるサンプルプログラムもリポジトリに大量にあります。 最初に私が書いたBPFプログラムはgistにあります。わずか40行と短く、Cの部分とPythonの部分に分かれています。 コードでは動作のしくみと面白みが見えにくいため、簡単に説明を加えます。 まずCの部分は、newobj_slowpathが実行されるたびに実行されます。このコードでは以下を行っています。 BPFハッシュ(基本的にはデータの保存や、Pythonフロントエンドから読み出せるユーザー空間への結果返却に使えるデータ構造)を宣言する 関数の最初の引数を(PT_REGS_PARM1で)読み取るcount関数を定義し、基本的にcounts[klass] += 1を実行する BPF_HASH(counts, size_t); int count(struct pt_regs *ctx) { u64 zero = 0, *val; size_t klass = PT_REGS_PARM1(ctx); val = counts.lookup_or_init(&klass, &zero); (*val)++; return 0; }; 次はPythonの部分ですが、これは1秒に1回countsを読み出すただのwhileループで(先ほどのBPFハッシュと同じなのですが、どういうわけかPythonから魔法のようにアクセスできるのです!!)内容を出力してからクリアしています。 counts = b.get_table(“counts”) while True: sleep(1) os.system(‘clear’) print(“%20s | %s” % (“CLASS POINTER”, “COUNT”)) print(“%20s | %s” % (“”, “”)) top = list(reversed(sorted([(counts.get(key).value, key.value) for key in counts.keys()]))) top = top[:10] for (count, ptr) in top: print(“%20s | %s” % (ptr, count)) counts.clear() というわけで42行のプログラムができあがりました。クラスごとのアロケーション数が刻々と更新される様子は実にクールです。素晴らしい。 ところでクラス名ってどうやって取ってるの? とりあえず、これも比較的簡単でした。「アロケートされた94477659822920のインスタンスは49個」などと表示されても自分にとって何の意味もないので、クラスのアドレスを表示するのは自分にとって不便です。 となると、俄然クラスごとに名前を取ってみたくなりました。ちょうどRubyにはこのためのrb_class2name関数があってとっても助かりました。これはクラスのポインタを受け取り、名前をchar *(文字列)で返します。 しかしRubyプロセスの内部にいなければこの関数を正確に呼び出すことはできません。そのとき「もしかして、呼び出せるんじゃ?」とひらめいたのです。Ruby内部をがっつりリバースエンジニアリングするよりはその方が楽そうに見えました。 目標は次の2つです。 rb_class2name関数を呼び出す プロファイリング対象のプロセスを決して邪魔しないようにする(当然ですが、プロセス内で関数を呼び出すのはダメですよ!) 結局、ポインタをクラス名にマッピングする別のRustプログラムをこしらえました。 参考: プログラミング言語 Rust Rubyプロセスのメモリマップをこちら側のメモリにコピーする rb_class2nameを呼び出すための私の(楽しくもヤバイ)企みは、基本的に対象のプロセスから全メモリマップをこちら側のプロファイラプロセスにコピーしてからrb_class2nameを呼び、後は祈るというものでした。 これなら対象のプロセスが持つメモリをごっそりこちら側にも持てます。後は自分のプロセス内の関数であるかのように、そのプロセスから関数を呼び出すだけです。 以下は、メモリマップをコピーする部分のコードスニペットです。copy_map関数の定義はここにあります。 基本的に、「syscall」と「vvar」という部分はコピーできないので、それらを除く全メモリマップをコピーします。これらの部分が何なのかはよく知りませんが、要らなさそうに思えました。 for map in maps … Continue reading Ruby: eBPFでメモリアロケーションをプロファイリングする(翻訳)