google-perftoolsを別のCPUに移植してみた

google-perftoolsというx86,x86_64,ppcUNIX向けのプロファイラの(cpu-profiler部分)を、armなLinuxに対応させてみました。何かの役に立つかもしれないので、patchおよびpatch作成作業のメモを載せます。arm-v5tアーキテクチャ(ARM9系)向けの移植です。


Linux/ARM向けのソフトウェアのパフォーマンスを解析したいなぁと思うことがあったのですが、OProfileはカーネル入れ替えがめんどくさい、gprofはプロファイル専用のバイナリを作成するのがめんどくさい、プロプラな奴は興味ないということで移植しました。移植の方がめんどくさいだろという話もありますが。perftools自体の説明はこちらが便利です。あーそういえばAndroidもARMでしたっけ?

パッチ


http://binary.nahi.to/google-perftools-0.93_armv5t_1.patch

パッチの適用方法とmake方法は次のとおりです。

host% tar xvzf google-perftools-0.93.tar.gz
host% cd google-perftools-0.93/
host% chmod +w aclocal.m4   (tarballのpermissionがおかしい模様)
host% patch -p1 < google-perftools-0.93_armv5t_1.patch
host% CC=armv5tel-redhat-linux-gnueabi-gcc CXX=armv5tel-redhat-linux-gnueabi-g++ ./configure \
           --host=arm-linux --enable-frame-pointer --prefix=/path/to/nfs_root/
host% sudo make install

以上でインストールが完了します。ARMマシン(以下target)上で、.soをPRELOADして解析対象を実行すると、prof.outという解析結果ファイルが生成されます。

target# export CPUPROFILE=/root/prof.out
target# export CPUPROFILE_FREQUENCY=100    (秒間最大何回プロファイルタイマをfireさせるか. デフォルト100, 最大4000)
target# LD_PRELOAD=/lib/libprofiler.so.0 /path/to/program
PROFILE: interrupts/evictions/bytes = 512/0/156

解析対象の /path/to/program は、-O0 または -O2 -fno-omit-frame-pointer でコンパイルされていることが必要です。stripされていてもOKです。


解析結果のprof.outファイルの内容を可視化するにはpprofコマンドを使います。pprofコマンドをARMマシン上で動かそうとすると、そちらにperl, file, binutils, graphvizくらいはインストールされていないとダメですが、これらを頑張ってARMマシンに入れなくても、次を満たしていればx86上でプロファイル結果を見られます。

  • 次のファイルがx86からアクセス可能
    • prof.out
    • 解析対象のprogram
    • programが依存しているDSO群
  • crossのbinutilsx86マシンにインストールされている

これは便利。

host% pprof --tools=/usr/bin/armv5tel-redhat-linux-gnueabi-  \
            --lib_prefix=/usr/armv5tel-redhat-linux-gnueabi/sys-root/  \
            /path/to/nfs_root/path/to/program /path/to/nfs_root/root/prof.out
Welcome to pprof!  For help, type 'help'.
(pprof) top
Total: 512 samples
     251  49.0%  49.0%      251  49.0% c
     152  29.7%  78.7%      152  29.7% b
      96  18.8%  97.5%       96  18.8% a
      13   2.5% 100.0%       13   2.5% wordexp
       0   0.0% 100.0%        2   0.4% _dl_signal_error
       0   0.0% 100.0%      510  99.6% __evoke_link_warning_getwd
       0   0.0% 100.0%      499  97.5% main
(pprof)

pprofにかける際のprogramは、strip前のものにしてください。pprof結果が怪しいときは、./configure で --enable-old-sighandler してみてください。うまく動かなかったりしたら教えてください。私は主にqemuとFedora6-armで動作確認してます。

移植方法


同ソフトウェアはアーキテクチャ依存部分が分離されているので、移植作業は楽で、

  1. ARMv5t用のアトミック演算関数を2種類書く
  2. スタックトレース関数を書く
  3. シグナルを受信したときのPCの値を得る関数を書く

の3stepで大体終わりです。順に。

1. アトミック演算関数を2種類書く (src/base/atomicops-internals-armv5t.h)


最低限、compare-and-swap(CAS)と、storeだけ書けばOKです。これは、ユーザ空間で完結したスレッドの排他制御に使われます。CASは、GCC4組込みのAtomic Builtins(__sync_ほげ() という関数群)が使えるアーキテクチャであれば、何も考えずにそれをwrapして終わりなのですが、ARM用は存在しない(コンパイルはできるけどリンクでundefined reference) ようなので、自分で書くことになります。ARMv5tでは、CAS実装に直接的に使える命令がありません。cmpxchg8b命令はv7アーキテクチャ(Cortex)から、ldrex/strex命令(=ll/sc)もv6アーキテクチャ(ARM11とか)からしか使えないそうで。v5の場合は...どうしよ。割り込みを禁止できないユーザモードのプロセスでCASするのは面倒ですね。


とりあえずswp/swpb命令というのは使えるので、それを使ってatomicに最大限近そうなCASを書くのが、速度と安全性のバランスが良いのかな。glibcが内部で(?)使っているatomic.hというファイル (glibc-ports-2.7/sysdeps/arm/bits/atomic.h) に実装例がありますねぇ。この実装は、2つあるswpの間のcmp命令実行の前後でコンテキストスイッチが起きるとまずいことになりますが*1、まープロファイラだし(?)、これでよしとします。

typedef int32_t AtomicWord;
inline AtomicWord Acquire_CompareAndSwap(volatile AtomicWord* ptr,
                                         AtomicWord old_value,
                                         AtomicWord new_value) {
    AtomicWord result; 

//#if __GNUC__ > 4 || (__GNUC__ == 4 && __GNUC_MINOR__ >= 1)
//    result = __sync_val_compare_and_swap(ptr, old_value, new_value);
//#else
    AtomicWord tmp; 
    __asm__ __volatile__ ("0:               \n\t"
                          "ldr %1,[%2]      \n\t"
                          "cmp %1,%4        \n\t"
                          "movne %0,%1      \n\t"
                          "bne 1f           \n\t"
                          "swp %0,%3,[%2]   \n\t"
                          "cmp %1,%0        \n\t"
                          "swpne %1,%0,[%2] \n\t"
                          "bne 0b           \n\t"
                          "1:"
                          : "=&r"(result), "=&r"(tmp)
                          : "r"(ptr), "r"(new_value), "r"(old_value)                      
                          : "cc", "memory");

//#endif
     return result;
}

次、release-storeですが、storeはCで書き、store直前にGCCの最適化を阻むバリアだけ置いとけばOKかと。例によってメモリ同期命令はv6tアーキ以降にしかない(筈)です。

inline void Release_Store(volatile AtomicWord* ptr, AtomicWord value) {
#if __GNUC__ > 4 || (__GNUC__ == 4 && __GNUC_MINOR__ >= 1)
  __sync_synchronize();
#else
  __asm__ __volatile__("" : : : "memory");
#endif
  *ptr = value;
}

NPTL専用でよければ、glibc-ports-2.7/sysdeps/unix/sysv/linux/arm/nptl/bits/atomic.h のほうを参考にする手もあるのかな。v5/v6両用のコードにできる模様。
なおARMの命令は、「RealViewコード生成ツールv2.0」の「アセンブラガイド」をダウンロードすれば全て載ってます。無料です。Web版もあったかも。

2. スタックトレース関数を書く (src/stacktrace_armv5t-inl.h)


次に、スタックのバックトレースを行なう関数 GetStackTrace() を書きます。バックトレースというのは、(gdb) bt すると表示されるあれのことです。この関数も1.同様、シグナルハンドラから呼ばれるので、シグナルセーフにつくるのがベターです。google-perftools-0.93/INSTALL 等にも、デッドロックの危険があるからGetStackTrace()でmallocは呼ばない方が良いのだと注記されていました。バックトレースを行なう関数というと、glibcにそのままの名前の関数 backtrace() というのがあり、#include するだけですぐ使えたりするんですが、この関数は内部でmallocを呼ぶことがあるのでできれば避けろと書かれてます。arm portでもそれに従い自作することにしました。ま、そんなに難しいわけではありません。


フレームポインタが省略されていないと仮定すると、arm-gccコンパイルしたコードの、関数の入口部分は次のようになってます。

% armv5tel-redhat-linux-gnueabi-objdump -d example
...
0000853c <main>:
    853c:       e1a0c00d        mov     ip, sp
    8540:       e92dd800        push    {fp, ip, lr, pc}

objdumpのバージョンによっては stmdb sp!, {fp, ip, lr, pc} と表示される場合もありますが、単に表記が違うだけでマシン語のバイト列は同じです。2行目のpushが実行された後のスタックのレイアウトは、こんな感じです。なので、bt関数は

#define OFFSET_FP_TO_SAVED_FP (-3)
#define OFFSET_FP_TO_LR         2

static void **NextStackFrame(void **old_sp) {
  void **new_sp = (void **) *old_sp;

  // initial value of fp regigster (at _start()) is 0x0.
  if ((uintptr_t)new_sp == 0) return NULL;
  new_sp += OFFSET_FP_TO_SAVED_FP;

  // Check that the transition from frame pointer old_sp to frame
  // pointer new_sp isn't clearly bogus
  if (new_sp <= old_sp) return NULL;
  if ((uintptr_t)new_sp - (uintptr_t)old_sp > 100000) return NULL;

  return new_sp;
}

int GetStackTrace(void **result, int max_depth, int skip_count) {
  void **sp;
  int n = 0;
  uintptr_t fp = 0;

  __asm__ __volatile__ ("mov %0, fp" : "=r"(fp));
  sp = (void **)fp;
  if ((uintptr_t)sp == 0x0) return 0;

  sp += OFFSET_FP_TO_SAVED_FP;
  while (sp && n < max_depth) {
    if ((uintptr_t)sp > 0xc0000000) {
      break;
    }
    if (skip_count > 0) {
      skip_count--;
    } else {
      result[n] = *(sp + OFFSET_FP_TO_LR);
      ++n;
    }
    sp = NextStackFrame(sp);
  }

  return n;
}

でよろしいのではないかと。説明端折りすぎ。x86版を多少書き換えただけです。インラインアセンブラ部分など。

3. シグナルを受信したときのPCの値を得る関数を書く (src/get_pc.h)


最後に、シグナルを食らったとき、どの命令を実行していたか(program counterの値)を戻す関数GetPCを書きます。シグナルハンドラをsigaction()で登録する際、SA_SIGINFOというフラグを指定しておくと、シグナルハンドラの第三引数としてucontext_t*という型の構造体を得ることができますので、この構造体の中に保存されているPC値を戻してやればOKです。handlerのシグネチャ等の詳細はman sigaction。

inline void* GetPC(const ucontext_t& signal_ucontext) {
  return (void*)signal_ucontext.uc_mcontext.arm_pc;
}

これだけ。ucontext_tの内容は、sys/ucontext.h (glibc-ports-2.7/sysdeps/unix/sysv/linux/arm/sys/ucontext.h) とか、asm/sigcontext.h (linux-2.6.2x/include/asm-arm/sigcontext.h) を参照すればわかります。


ただ、カーネルが古いと(?)、SA_SIGINFOのucontext_t経由ではPCの値が取れないようです。手元の2.6.10カーネルではダメでした。catchsegvコマンドのソースコードである glibc-2.7/debug/segfault.c を参考に、SA_SIGINFOを指定しないでsigaction()し、glibc-ports-2.7/sysdeps/unix/sysv/linux/arm/sigcontextinfo.h のSIGCONTEXTマクロやGET_PC()マクロを使う方法なら、きちんとPCを取ることができました。詳しい事情をだれか教えて的。とりあえず、configureオプションで、こちらの実装も選択できるようにしておきました。詳細はパッチ見てください。


以上です。以下はおまけ。

perftoolsのしくみと、i386向けGetPC実装について


arm版GetPC関数の実装は上に書いたとおりの簡素なものですが、実はx86版のGetPCの実装はもうすこし凝っています。その概要をメモとして残しておきます。google-perftoolsは、測定対象のプログラムにLD_PRELOAD等で割り込んで、main関数の前でsetitimer(ITIMER_PROF)システムコールを呼び、測定対象プロセスに一定周期でSIGPROFシグナルが飛んでくるようにします。SIGPROFのハンドラもperftoolsが用意していて、その中でさきほどのGetStackTrace()を使ったbacktrace処理が行なわれます。main()がfoo()を呼び、foo()がbar()を呼び、bao()の中でSIGPROFを食らったとすると、backtraceは当然、

prof_handler()     <-- シグナルハンドラ
bar()
foo()
main()

となるわけですが、bar()関数のプロローグ処理の前や、エピローグ処理の後にシグナルを食らうと、foo関数用のスタックフレームが構築前あるいは解体後であるため、バックトレースが

prof_handler()     <-- シグナルハンドラ
foo()
main()

となってしまいます。


perftoolsは、バックトレースの先頭の2関数を無視し、残りのトレースとGetPC()で取得したPCが指している関数をつないでコールグラフを作成するので、後者の場合だと、mainが直接barを呼ぶ、おかしなコールグラフが作成されてしまいます。これを避けるために、x86版のGetPCでは、フレーム構築前・解体後にGetPCされたときは、bar内のアドレスでは無く、foo内のアドレスを戻すように細工が行なわれます。arm版ではこの処理は行なっていません。TODOということで。

GetStackTrace()とフレームポインタ省略について


GetStackTrace()は、スタックに保存されたfp(フレームポインタ)を辿っているだけですので、もし解析対象のプログラムや、google-perftools自身が -fomit-frame-pointer (フレームポインタ省略) でコンパイルされていると、うまく動きません。ARM用のGCCは、x86とは違って -O2 で最適化すると自動で -fomit-frame-pointer してしまうので、注意が必要です。明示的に -O2 -fno-omit-frame-pointer で両者をコンパイルしてやらないとだめです。google-perftoolsでは、./configure --enable-frame-pointer でそのようなコンパイルが行なわれることにしているようでしたので、arm portもそれに従いました。


詳しくありませんがおそらく、-fomit-frame-pointerされたバイナリであっても、ELFの.eh_frameセクションの情報(DWARFv3 CFI?)を見ればバックトレースできるのだと思います。実際armなgdbは-O2 binaryでも平気でbt可能ですし。perftoolsでは、他のarchでもそこまで頑張っていないので、arm版でもfpを辿るだけの素朴な実装にしました。perftoolsはlibunwindによるスタック巻戻しにも対応しているので、もしlibunwindがarmに対応しているなら、そっちを使う手もあるかもですね。あーだめか。http://www.nongnu.org/libunwind/download.html によると A complete open-source implementation of the libunwind API currently exists for IA-64 Linux and is under development for Linux on x86, x86-64, and PPC64.だわ、ダメですね。

arm用の、プリインストールなDSO (/lib/libc.so とか) は、大抵-O2でコンパイルされているとおもいますので、そういうDSO内でSIGPROFが配送された場合にはコールグラフが少しおかしなものになります。これも制限事項ということで。

CASの件


1,のCASですが、思わずglibcを全面的に参考にしてコード書いてしまったのでperftoolsのライセンス的にどうなのよという話も。もっと無難な方法、ふがふが.hを#includeしてほげほげ関数使えばOKだよ的な方法があれば教えてください。ちょっと調べた限りでは、

  • linux kernelのasm-arm/atomic.h は、ユーザプログラムからは使えない
    • pre-v6向けコードでは、非SMP前提でlocal_irq_save()して割り込みを無効にしている為
  • Qtにそれらしい関数群がある (/usr/include/Qt/qatomic_arm.h) けど、TASだけでCASはなさげだし、Qtに依存するのもなんだかなー
  • libstdc++のatomicity.h (/usr/lib/gcc/armv5tel-redhat-linux-gnueabi/4.1.2/include/c++/bits/atomicity.h) にもCASはない
  • glibcソースコードにはarm用のatomic.h (or 古めのglibcだと atomicity.h) が含まれていて、near-atomicなわけだけど、私の使っているtoolchainのinclude directory以下にはどちらもインストールされていない
  • atomic_ops projectもARMは対応してない

という感じでうまい策がありません。何か根本的な勘違いがある気もしますが...。


v5のswpb命令でバイナリセマフォは問題なく実装でき、かつperftoolsではAcquire_CompareAndSwap()とRelease_Store()はスレッドの排他にしか使われないので、いっそのことAcquire_CompareAndSwap()をswpbなセマフォのP操作に、Release_Store()を同V操作に読みかえてしまおうか。。。汚いけど。

その他


patchの動作確認は、qemu-system-arm上でFedora Core 6 for ARMを動作させて行ないました。カーネルhttp://fedora-arm.wantstofly.org/qemu/zImage-versatile-2.6.22 、rootfsは http://fedora-arm.wantstofly.org/rootfs/fc6-arm-root-with-gcc.tar.bz2 、クロスコンパイラ類は http://fedora-arm.wantstofly.org/cross/latest/i386/*.rpm を使い、How To: Running Fedora-ARM under QEMU に従って起動させたものです。Fedora-ARMは、yumも使えてなかなか快適です。ArmadilloとかAndroid上でも動くんじゃないかと思います。LinuxThreadsを使っている場合には、signal回りで若干の追加コードがいるかなぁ。あとでそういうARMボードの上で動かして試すこと>私。

*1:少し考える or SPIN等で検査すれば例を提示できそうですが眠いので略。ちなみにv6以降ではswp/swpbは非推奨命令。後述の qatomic_arm.hみたいに、グローバルなロックをひとつだけ抱えて、どこでコンテキストスイッチしても問題の起きないv5用CASを実装する手もあるのかと思いますが、バイナリセマフォを実現するためのCASの中で別の方法(swpb)で実装したバイナリセマフォをP/Vするのは激しくイマイチな感じなのでやめときます。おかしなこと書いていたらすみません。関係ないけど、NetBSDではrestartable atomic sequenceという仕組みでuser/kernelが協調することで低機能なCPUでもCAS等を実装可能にしているんですね。atomic sequence中にコンテキストスイッチすると、カーネルがそれを検出してatomic seqを最初から実行しなおさせると。コンテキストスイッチしなければランタイムコスト0。http://db.usenix.org/publications/library/proceedings/usenix03/tech/freenix03/full_papers/mcgarry/mcgarry_html/index.html このアイディアは素敵だなぁ。