シグナルを使って statistical profiler をつくろう

Pythonの標準ライブラリにはプロファイラがあるが、これは deterministic profiler という種類のプロファイラだ。関数が呼び出されたときと関数から抜けるときに登録したコールバック関数を呼び出す仕組みが用意されており、そのコールバック関数で時間を測ることで、各関数の実行時間を測ることができる。

このプロファイラは、短時間の実行で正確な時間を計測できるので、マイクロプロファイリングには適している。しかし、プロファイルを実行するとプログラムの実行速度がかなり低下してしまうという問題もある。

deterministic profiler と別の方式に、 statistical profiler というものがある。
これは、何らかの周期でプログラムのどこを実行しているのかをサンプリングするものだ。実行時間の長い関数ほど何度もサンプリングされるので、サンプル数が実行時間に比例する。
この方式の利点は、サンプリング周期が十分に長ければ、アプリケーションの速度低下を最低限に抑えられることだ。

さて、「何らかの周期」だけど、これは setitimer が便利だ。実時間、CPU時間(USER)、CPU時間(USER+SYSTEM)の3種類の周期でシグナルを発行してくれる。これを使って簡単な statistical profiler を作ってみよう。

import sys
import signal
from collections import defaultdict

_counter = defaultdict(int)
_cum_counter = defaultdict(int)

def _sig_handler(signal, frame):
    code = frame.f_code
    _counter[code] += 1
    _cum_counter[code] += 1
    s = set()
    s.add(code)
    f = frame.f_back
    while f is not None:
        code = f.f_code
        if code not in s:
            _cum_counter[f.f_code] += 1
            s.add(code)
        f = f.f_back

def install(target='cpu', interval=0.01):
    if target == 'cpu':
        ttype = signal.ITIMER_PROF
        sigtype = signal.SIGPROF
    elif target == 'user':
        ttype = signal.ITIMER_VIRTUAL
        sigtype = signal.SIGVTALRM
    elif target == 'real':
        ttype = signal.ITIMER_REAL
        sigtype = signal.SIGALRM

    signal.signal(sigtype, _sig_handler)
    signal.setitimer(ttype, interval, interval)

    import atexit
    atexit.register(_show_profile)

def _print_profile(counter, total):
    items = counter.items()
    items.sort(key=lambda x: x[1], reverse=True)
    for code, count in items:
        print "%20s:%-4d %20s %f" % (code.co_filename, code.co_firstlineno,
                                     code.co_name, 100.0*count/total)

def _show_profile():
    total = sum(_counter.itervalues())
    print "== time =="
    _print_profile(_counter, total)
    print "== cumulative time =="
    _print_profile(_cum_counter, total)

def test1():
    n=0
    while n < 10**5:
        n+=1

def test2():
    n=0
    while n < 10**6:
        n+=1

def loop():
    write=sys.stdout.write
    from time import time
    while True:
        test1()
        test2()

if __name__ == '__main__':
    install()
    loop()

このスクリプトをしばらく実行したあとに Ctrl-C で止めると、次のような出力が得られる。

== time ==
         sprofile.py:58                  test2 90.737012
         sprofile.py:53                  test1 9.262988
== cumulative time ==
         sprofile.py:1                <module> 100.000000
         sprofile.py:63                   loop 100.000000
         sprofile.py:58                  test2 90.737012
         sprofile.py:53                  test1 9.262988

USR1 などのシグナルを受けたらプロファイル結果を表示するなどのカスタマイズをすれば、デーモン型のプログラムのプロファイリングが簡単にできるはずだ。

あと、 frame オブジェクトには実行中の行数も入っているので、関数の中のどの部分が重いのかが判らない場合はそれもサンプリングするといいだろう。

このブログに乗せているコードは引用を除き CC0 1.0 で提供します。