WSGI ミドルウェアとして使えるラインプロファイラを作った話

この記事は CAMPHOR- Advent Calendar 2016 の1日目の記事です.

Python の WSGI ミドルウェアとして使えるラインプロファイラ wsgi_lineprof を作成しました. wsgi_lineprof は Django や Pyramid, Flask, Bottle などの WSGI 互換のアプリケーションやフレームワークと組み合わせて利用できるようになっています.

背景

Web アプリケーションを作成する際に, ボトルネックとなっている部分を発見するために, ラインプロファイラは有用です. Ruby での WSGI に対応する Rack には rack-lineprof というライブラリがあり, Rack の middleware として簡単にラインプロファイラを利用できます. ISUCON の参加記事等を見ていると, Ruby ユーザーはこのライブラリを活用しているようで, 羨ましいなという思いがありました.

Python では line_profiler というラインプロファイラーがありますが, kernprof という専用のコマンドを使って Python スクリプトを実行する必要があり, Web アプリケーションのプロファイリングには決して使いやすいものとは言えませんでした.

概要

wsgi_lineprof は WSGI のミドルウェアとして利用できるラインプロファイラです. Python の多くの Web アプリケーションやフレームワークは WSGI に対応しているため, これを簡単に利用することができます. wsgi_lineprof は Web アプリケーションへのリクエストのたびに, 実行された全ての関数と関数内の各行の実行時間を取得し, その結果を出力します.

サンプルプログラム

この記事では, Bottle で実装された以下のようなアプリケーションを利用します. 次のファイルを app.py として保存します:

import time
import bottle

app = bottle.default_app()

def get_title():
    # DB アクセス等の代わりに1秒待つ
    time.sleep(1)
    return "Monty Python"

def fact(n):
    if n < 2:
        return 1
    return n * fact(n - 1)

@app.route('/')
def index():
    title = get_title()
    number = 500
    return "Title: {}\n{}! = {}".format(title, number, fact(number))

if __name__ == "__main__":
    bottle.run(app=app)

python app.py としてこのプログラムを実行し, http://localhost:8080 にアクセスすると次のようなレスポンスが返ってきます.

Title: Monty Python
500! = 1220136825991110068701238785423046926253574342803192842192413588385845373153881997605496447 (省略)

wsgi_lineprof の使い方

wsgi_lineprof は pip で簡単にインストールできます: pip install wsgi_lineprof インストール時に Cython による拡張または C による拡張をコンパイルするので, GCC 等のコンパイラが入っている必要があります. (今後 wheel 等での配布を充実させていく予定です.)

先ほどの app.pyapp に wsgi_lineprof をミドルウェアとして追加します:

from wsgi_lineprof.middleware import LineProfilerMiddleware

(中略)

if __name__ == "__main__":
    app = LineProfilerMiddleware(app)
    bottle.run(app=app)

再び python app.py としてこのプログラムを実行し, http://localhost:8080 にアクセスします. すると, 標準出力に以下のようなプロファイリングの結果が出力されます:

Bottle v0.12.10 server starting up (using WSGIRefServer())...
Listening on http://127.0.0.1:8080/
Hit Ctrl-C to quit.

Time unit: 1e-06 [sec]

File: /Users/yusuke/.virtualenvs/wsgi-lineprof/lib/python3.5/site-packages/bottle.py
Name: __init__
Total time: 5e-06 [sec]
  Line      Hits         Time  Code
===================================
  1929                             def __init__(self, *a, **ka):
  1930         1            4          self.dict = {}
  1931         1            1          if a or ka: self.update(*a, **ka)

File: /Users/yusuke/.virtualenvs/wsgi-lineprof/lib/python3.5/site-packages/bottle.py
Name: fset
Total time: 5e-06 [sec]
  Line      Hits         Time  Code
===================================
  1656         6            5      def fset(self, value): ls.var = value

File: ./app.py
Name: fact
Total time: 0.001151 [sec]
  Line      Hits         Time  Code
===================================
    16                         def fact(n):
    17       500          306      if n < 2:
    18         1            0          return 1
    19       499          845      return n * fact(n - 1)

(以下省略)

プロファイリングの結果は各メソッド・関数ごとに表示されます. まず, 先頭の行からこのプロファイル結果の時間の単位は1μs (マイクロ秒) であることがわかります.

Time unit: 1e-06 [sec]

以降は各関数ごとの結果です. 次の結果からは, app.pyfact 関数のプロファイル結果が得られます. 関数全体の実行時間は 0.001151 秒で, それぞれの行が実行された回数と, かかった総時間 (例: 17 行目は500回実行され, 500回の実行にかかった時間は306μs) が分かります.

File: ./app.py
Name: fact
Total time: 0.001151 [sec]
  Line      Hits         Time  Code
===================================
    16                         def fact(n):
    17       500          306      if n < 2:
    18         1            0          return 1
    19       499          845      return n * fact(n - 1)

このリクエストを処理する間に利用した全てのメソッドの結果が表示されるため, フレームワーク等を利用している場合は結果が膨大になります. 結果を見やすくするために, ファイル名で表示する範囲を指定したり, 実行時間の遅い順にソートすることができます.

フィルター

wsgi_lineprof ではフィルターと呼ばれる機能を用いて, プロファイル結果を整理することができます. wsgi_lineprof.filters.FilenameFilter は特定のファイル名のもののみの絞り込み, wsgi_lineprof.filters.TotalTimeSorter では total time の長い順にソートすることができます. フィルターはミドルウェアを設定する際に一緒に設定します:

from wsgi_lineprof.filters import FilenameFilter, TotalTimeSorter

(中略)

if __name__ == "__main__":
    filters = [
        FilenameFilter("app.py"),
        TotalTimeSorter()
    ]
    app = LineProfilerMiddleware(app, filters=filters)
    bottle.run(app=app)

このプログラムを実行すると, 次のような結果が得られます:

Bottle v0.12.10 server starting up (using WSGIRefServer())...
Listening on http://127.0.0.1:8080/
Hit Ctrl-C to quit.

Time unit: 1e-06 [sec]

File: ./app.py
Name: index
Total time: 1.00402 [sec]
  Line      Hits         Time  Code
===================================
    18                         @app.route('/')
    19                         def index():
    20         1      1001447      title = get_title()
    21         1            1      number = 500
    22         1         2576      return "Title: {}\n{}! = {}".format(title, number, fact(number))

File: ./app.py
Name: get_title
Total time: 1.00141 [sec]
  Line      Hits         Time  Code
===================================
     8                         def get_title():
     9                             # DB アクセス等の代わりに1秒待つ
    10         1      1001409      time.sleep(1)
    11         1            4      return "Monty Python"

File: ./app.py
Name: fact
Total time: 0.001369 [sec]
  Line      Hits         Time  Code
===================================
    13                         def fact(n):
    14       500          337      if n < 2:
    15         1            1          return 1
    16       499         1031      return n * fact(n - 1)

127.0.0.1 - - [30/Nov/2016 22:08:20] "GET / HTTP/1.1" 200 1162

フィルターを使うことでライブラリ内部の処理時間は表示せずに, 目的のファイルだけのプロファイル結果を表示することが出来ました!

いくつかの組み込みのフィルターが wsgi_lineprof.filters に定義されている他に, ラムダ式 (Iterable[wsgi_lineprof.stat.LineProfilerStat] をとって Iterable[wsgi_lineprof.stat.LineProfilerStat] を返すもの) を用いて簡単にユーザー定義のフィルターを利用できます. 例えば total time が 0.001 秒以上の結果のみを表示するには, 次のようにします:

if __name__ == "__main__":
    filters = [
        lambda stats: filter(lambda stat: stat.total_time > 0.001, stats),
    ]
    app = LineProfilerMiddleware(app, filters=filters)
    bottle.run(app=app)

ファイルへの出力

プロファイル結果をファイルに出力したい場合は stream 引数を利用します. 次の例では lineprof.log にプロファイル結果を出力します.

if __name__ == "__main__":
    with open("lineprof.log", "w") as f:
        app = LineProfilerMiddleware(app, stream=f)
        bottle.run(app=app)

その他

wsgi_lineprof で実行時間を取得する部分は line_profiler を参考に Cython で実装しています. 具体的には, C API の PyEval_SetTrace を通じてデータを取得しています.

また wsgi_lineprof では, 型ヒントを積極的に記述するようにしています. PyCharm や mypy などで, 静的型検査のメリットを享受できるはずです.

まとめ

この記事では WSGI ミドルウェアとして利用できる新しいラインプロファイラ wsgi_lineprof について紹介しました. このプロファイラが Web アプリケーション・フレームワークの開発や ISUCON 等のコンテストに少しでも役立てば幸いです.

wsgi_lineprof は今後も少しずつ改善していく予定です. バグ修正や改善等の PR は GitHub で受け付けています. リポジトリへの star もお待ちしてます!

CAMPHOR- Advent Calendar 2016 2日目の担当は @ytmatsuge です. お楽しみに!!