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

概要

原著者の許可および指示により「非公式の翻訳として」翻訳・公開いたします。

なお、eBPFはBPF(Berkeley Packet Filter)の拡張版(extended BPF)です。

参考: LinuxのBPF : (1) パケットフィルタ - 睡分不足
参考: LinuxのBPF : (3) eBPFの基礎 - 睡分不足

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

今回は、CPUプロファイラを使う代わりに、まったく新しいアイデアに基づいた実験を披露いたします。

私がその日の朝に思いついたのは、Rubyの任意のプロセスのPID(既に実行されているプロセスのPID)を取得してメモリアロケーションを追えるのではないかというものでした。

ネタバレ: 一応動きました!その様子をasciinemaのデモでご覧いただけます。ここでは、rubocopのメモリアロケーションをクラスごとにカウントしたものを15秒間に渡って刻々と累積的に表示しています。rubocopが数千個ものArrayStringRange、そして若干の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つです。

  1. rb_class2name関数を呼び出す
  2. プロファイリング対象のプロセスを決して邪魔しないようにする(当然ですが、プロセス内で関数を呼び出すのはダメですよ!)

結局、ポインタをクラス名にマッピングする別のRustプログラムをこしらえました。

参考: プログラミング言語 Rust

Rubyプロセスのメモリマップをこちら側のメモリにコピーする

rb_class2nameを呼び出すための私の(楽しくもヤバイ)企みは、基本的に対象のプロセスから全メモリマップをこちら側のプロファイラプロセスにコピーしてからrb_class2nameを呼び、後は祈るというものでした。

これなら対象のプロセスが持つメモリをごっそりこちら側にも持てます。後は自分のプロセス内の関数であるかのように、そのプロセスから関数を呼び出すだけです。

以下は、メモリマップをコピーする部分のコードスニペットです。copy_map関数の定義はここにあります。

基本的に、「syscall」と「vvar」という部分はコピーできないので、それらを除く全メモリマップをコピーします。これらの部分が何なのかはよく知りませんが、要らなさそうに思えました。

for map in maps {
    if map.flags == "rw-p" {
        copy_map(&map, &source, PROT_READ | PROT_WRITE).unwrap();
    }
    if map.flags == "r--p" {
        copy_map(&map, &source, PROT_READ | PROT_WRITE).unwrap();
    }
    if map.flags == "r-xp" {
        copy_map(&map, &source, PROT_READ | PROT_WRITE | PROT_EXEC).unwrap();
    }
}

rb_class2nameを呼び出す

rb_class2nameの呼び出しは割りと簡単です。必要だったのは、rb_class2nameのアドレスを何とかして見つけ(rbspyで行う方法は既にわかっていました)、アドレスを正しい関数ポインタにキャストして(extern "C" fn (u64) -> u64)から、得られた関数を呼び出すことだけでした。

もちろん、Rustではメモリマップのコピー/(本質的にランダムな)アドレスの関数ポインタへのキャスト/得られた関数の呼び出しはすべてunsafeですが、それでもできました。

同日夜9時には最終的に動くようになったので、喜びでいっぱいの気持ちになれました。

segfault

クラスのポインタを名前に変換するときにちょくちょくsegfaultしました。この方法で動くことをデモしたいだけなので、この部分をがっつりデバッグする代わりにsegfaultを無視する方向で頑張ることにしました。segfaultは常に発生するわけではなく、ときどきしか起きなかったので。

やったのは以下です(アホなことしてますが、こういうところが楽しいんですよね)。

  1. segfaultする部分に差しかかる寸前にforkする
  2. 子プロセス内で、segfaultする可能性のある部分を強引に実行して結果を出力する
  3. 子プロセスがsegfaultしたら無視してとっとと先へ進む

これでうまくいきました。

RustとPythonを協調動作させる方法

最終的には以下のようにデモを動かしました。

  1. Pythonプログラムは、クラスのポインタの取得と、クラスのアロケーション回数のカウントアップを担当する(uprobesとBPFを利用)
  2. Rustプログラムは、クラスポインタからクラス名へのマッピングを担当する: PIDとクラスポインタのリストをRustプログラムの引数として渡すと、STDOUTにマッピングを出力する

言うまでもなく隅から隅までヤバいハックですが、1日つぶして作業しただけでうまく動くようになったので、最高に幸せでした。コンパイルして適切なBPFプログラムを保存する部分さえ何とかなれば、ここで行ったことは全部Rustでできると思います。bccを使わなくても、Rustで正しいシステムコールを呼んでコンパイル済みBPFプログラムをカーネルに挿入できるはずだと考えています。

設計の原則: 魔術っぽさを目指す

ここで用いた私の設計の主要な原則は「魔術っぽく見えるツールをいかに構築するか」でした(きっと実用にも役立つことがあるよね!)。しかしeBPFを使えば相当スゴイことができるのですから、どうにかしてそのスゴさを皆にわかるような形にしたいと思っています。

Rubyプロセスでどのメモリがアロケーションされているかをストリーミングっぽく生中継する(しかも事前にRubyプログラムに一切手を加える必要なく)というアイデアは、我ながら実に魔術的かつクールだと思っています。実用化のためにやらなければならないことはまだ山ほどありますし、安定して動作させる方法について見通しがついているわけでもありませんが、このデモが動いたことが本当に嬉しかったのです。

ご質問/ご感想はこの記事のTwitterスレまでどうぞ。

関連記事

Rubyのメモリ割り当て方法とcopy-on-writeの限界(翻訳)

Ruby内部の文字列を共有してスピードアップする(翻訳)

Rubyのヒープをビジュアル表示する(翻訳)

デザインも頼めるシステム開発会社をお探しならBPS株式会社までどうぞ 開発エンジニア積極採用中です! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833 コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。 これまでにRuby on Rails チュートリアル第2版の半分ほど、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れてそれぞれ一部を翻訳。 かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。 実は最近Go言語が好き。 仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

週刊Railsウォッチ

インフラ

ActiveSupport探訪シリーズ