Python を速くする取り組み

速い Python 実装といえば PyPy が有名ですが、 Python 3 へのキャッチアップが遅い、 CPython が持っている Python/C API のサポートがまだ弱く遅い、などの欠点があります。

また、 Google の1年プロジェクトだった Unladen Swallow もありました。これは CPython をフォークして LLVMJIT を実装するものでした。この fork 実装は終わりましたが、この時期まだ不安定だったLLVMへの貢献は大きく、(ちゃんとおってないので憶測ですが)現代LLVMを利用したJITを実装しているプロジェクトは全部間接的に Unladen Swallow の成果の上に成り立っていると言えるかもしれません。

終了した JIT プロジェクトといえば、 psyco もありました。これはベタに CPython の JIT を実装していましたが、メンテが大変なのと PyPy に性能で追いぬかれたので終了しました。

他の速度を目的とした Python 実装としては Dropbox の Pyston があります。これも PyPy と同じく Python 2 がメインターゲットですし CPython との互換性も低いですが、より保守的な JIT を実装しています。最近 Nexedi が Pyston の開発に加わりました

また、 MicroPython という組み込み向けの Python もあります。これは CRuby と mruby の関係に似ていると思います。 CPython のしがらみに囚われずにいろいろな動的言語の高速化手法を取り込めるため、 CPython より速いのですが、PC上で普段使いとして使うにはライブラリのカバーしてる範囲が狭いのが欠点です。こっちは pyston と違って Python 3 の実装です。

最近活発な CPython を高速化するプロジェクトが2つあります。 FAT PythonMicrosoftPyjion です。

FAT Python は CPython を高速化にしようとするプロジェクトです。 Ruby 2 系で行われているようなメソッドキャッシュの改良が試みられていたり、さらにアグレッシブに、 AST レベルでのインライン展開や定数の畳み込みといった試みをプラガブルにしようとしています。もちろん Python 3 ベースです。

Pyjion も CPython ベースで、 JIT エンジンを CPython に付けられる用にする、その JIT の参照実装というか実効性の検証用に、 CoreCLR を使ったJITを組み込もうというものです。 IronPython と違って Python そのものの .NET ベースの実装というわけではなくてあくまでも CPython の拡張を提案するプロジェクトです。 まだ詳細は発表されていませんが、もうすぐ PyCon で発表があるはずです。

個人的には、活発化してきた PyPy3 の開発が進んでくれることと、 FAT Python の成果の一部が Python 3.6 に間に合うことを期待しています。

CPython の GC チューニング

ISUCON は Go で参戦しているんだけど、複数のチームが Python で予選通過したらしいので、応援のために Tips を公開していこうと思う。

目次

CPython の GC について

CPython のGCは参照カウント+循環参照コレクタだ。そして参照カウント方式は(幾つかの欠点はあるものの)Webアプリのボトルネックになったりはしにくい。 なのでGCチューニングの基本は次のようになる。

  1. 循環参照を避ける
  2. 循環参照コレクタの呼び出しタイミングを制御する

循環参照コレクタは、生きているオブジェクトの数がある程度増えると第一世代が実行され、第一世代が一定回数実行されると第二世代が、第二世代が一定回数実行されると第三世代が実行される。

各世代を実行するしきい値は次のAPIで参照・設定が可能だ。

In [1]: import gc

In [2]: print(gc.get_threshold())
(700, 10, 10)

In [3]: gc.set_threshold(1000, 10, 10)

In [4]: print(gc.get_threshold())
(1000, 10, 10)

デフォルトでは、700個オブジェクトが増えた時点で第一世代GCが起動し、第二世代と第三世代は前世代GCが10回起動する度に実行される。

重要なのは、第一世代GCが起動する条件が「循環参照してるオブジェクト」ではなく「生きてるオブジェクト」が増えた時だということだ。*1

例えば複雑なJSONを処理するプログラムでは、JSONは循環する構造を持っていないにもかかわらず、JSONデコード中に何度も循環参照コレクタが起動してスピードを落としてしまう。

統計情報を出力する

プログラムのエントリーポイントなどで、GCが統計情報を出力するようにしておく。

import gc
gc.set_debug(gc.DEBUG_STATS)

これで標準エラー出力に、GCが実行される度に統計情報が出力される。

import gc
gc.set_debug(gc.DEBUG_STATS)

while True:
    d = {}
    d['key'] = d
...
gc: collecting generation 1...
gc: objects in each generation: 782 11 5083
gc: done, 791 unreachable, 0 uncollectable, 0.0001s elapsed
...

最初の行は、世代を0からカウントしているので1と表示されているので、第二世代のGCを開始していることを示している。

次の行は、GC実行前の各世代のオブジェクト数で、若いオブジェクトが782、第一世代GCを生き残ったオブジェクトが11、第二世代GCを生き残ったオブジェクトが5083あることを示している。

次の行で、 791 個のオブジェクトが unreachable (到達不能) で消されている。 0 uncollectable はすべての到達不能オブジェクトが回収できたことを示している。*2

第一世代GCの間隔を調整する

Web アプリのような場合、リクエストごとにたくさんのオブジェクトを作っては捨てる。 Python の場合その殆どがリクエストの処理の終了時に参照カウントで自動的に回収されるはずだが、第一世代GCしきい値が低いとそれらのオブジェクトのために循環参照GCが実行されてしまう。

本物のWebアプリの代わりに次のような簡単なサンプルを見てみる。GCの動作が見やすいようにしきい値は調整している。

import gc
import sys
gc.set_debug(gc.DEBUG_STATS)
gc.set_threshold(500, 2, 2)


def app():
    x = []
    for i in range(2000):
        x.append([])


def main():
    for i in range(5):
        print('start:', i, file=sys.stderr)
        app()
        print('end', file=sys.stderr)


main()
...
start: 2
gc: collecting generation 0...
gc: objects in each generation: 583 0 4759
gc: done, 0.0000s elapsed
gc: collecting generation 1...
gc: objects in each generation: 504 581 4759
gc: done, 0.0001s elapsed
gc: collecting generation 2...
gc: objects in each generation: 504 0 5842
gc: done, 0.0013s elapsed
end
start: 3
gc: collecting generation 0...
gc: objects in each generation: 576 0 4750
gc: done, 0.0000s elapsed
...

このログを見ると、次の2つのことがわかる

  • 循環参照コレクタが1つもオブジェクトを開放できていない (N unreachable, が表示されていない)
  • app() の呼び出しが終わるごとに 1000 個前後のオブジェクトが (参照カウンタによって) 消えてる

このことから、第一世代GCしきい値を倍の2000程度にすれば、循環参照コレクタの起動を抑制できることがわかる。

import gc
import sys
gc.set_debug(gc.DEBUG_STATS)
gc.set_threshold(2000, 2, 2)
# ...以下同じ
start: 0
gc: collecting generation 1...
gc: objects in each generation: 2041 4417 0
gc: done, 0.0009s elapsed
end
start: 1
end
start: 2
end
start: 3
end
...

最初だけ、多分 Python 起動時に1000個以上のオブジェクトを確保していたためにGCが起動しているが、その後のリクエストの処理では一切循環参照コレクタが実行されていないことがわかる。

Out of Band GC

循環参照オブジェクトがあって完全には循環参照コレクタを抑止できない場合もある。

その場合、 例えば GC を止める・Ruby ウェブアプリケーションの高速化 にあるように、レスポンスを返したあと、次のリクエストを受け取る前に循環参照コレクタを実行することで、GCの実行時間がレスポンスタイムに与える影響を最小限にすることができる。 (=Out of Band GC, OOBGC)

純粋な wsgi アプリケーションは関数呼び出し=アプリケーションの実行であるため凝ったことをしないと OOBGC ができないが、アプリケーションサーバーによっては OOBGC を可能にする hook を提供している。

特に uWSGI の場合、 uwsgi プロトコルで keep-alive なしで nginx の配下に置くのが一般的なので、OOBGCが完了したプロセスが次のリクエストを accept() することができるので効果的だ。

# wsgi.py
import gc
import uwsgi
import myapp


application = myapp.get_app()

gc.set_threshold(10000, 10, 10)
uwsgi.after_req_hook = lambda: gc.collect(1)

循環参照を見つけて対処する

gc.DEBUG_STATS により、GCの実行時間が長く、また大量の unreachable が検出されている場合は、循環参照が原因だ。その場合は個別に循環参照を潰していく必要がある。

まずは循環参照を見つけるために、 gc.DEBUG_COLLECTABLE を使って、循環参照に入っていたオブジェクトの型を見る。

import gc
gc.set_debug(gc.DEBUG_STATS | gc.DEBUG_COLLECTABLE)

for _ in range(10000):
    d = {}
    d['key'] = d
...
gc: done, 780 unreachable, 0 uncollectable, 0.0617s elapsed
gc: collecting generation 2...
gc: objects in each generation: 365 11 4750
gc: collectable <dict 0x7fe7be594d08>
gc: collectable <dict 0x7fe7be58f048>
gc: collectable <dict 0x7fe7be58c9c8>
gc: collectable <dict 0x7fe7be589ac8>
gc: collectable <dict 0x7fe7be589a88>
gc: collectable <dict 0x7fe7be589a48>
gc: collectable <dict 0x7fe7be589a08>
gc: collectable <dict 0x7fe7be5899c8>
gc: collectable <dict 0x7fe7be589988>
gc: collectable <dict 0x7fe7be589948>
gc: collectable <dict 0x7fe7be589908>
...

型だけではわからない場合、 gc.DEBUG_SAVEALL を使う。このオプションを使うと本来回収されるオブジェクトを gc.garbage というリストに入れてくれるので、コールバックで gc.garbage の中の注目してる型のオブジェクトの情報を表示して、 gc.garbage をクリアしてやる。

import sys
import gc
gc.set_debug(gc.DEBUG_STATS | gc.DEBUG_SAVEALL)


@gc.callbacks.append
def after_gc(phase, info):
    if phase == "start":
        return
    for o in gc.garbage:
        if isinstance(o, dict):
            print("gc dict keys=", list(o), file=sys.stderr)
    gc.garbage.clear()


for _ in range(10000):
    d = {}
    d['key'] = d
...
gc dict: ['key']
gc dict: ['key']
gc dict: ['key']
gc dict: ['key']
gc dict: ['key']
...

これで、 'key' というキーを持つ dict が原因だとわかった。gc.garbage から見つけたオブジェクトがどうやって循環参照に加担しているかを調べるために、他にも gc.get_referrers()gc.get_referents() がある。

さて、これで問題の dict が見つかった。循環参照を壊してやるには、使い終わった dict の .clear() メソッドを呼べばいい。

import sys
import gc
gc.set_debug(gc.DEBUG_STATS)


for _ in range(10000):
    d = {}
    d['key'] = d
    ...
    d.clear()

GC方式の違いから、PythonRuby に比べてWebアプリケーションでGCの時間がパフォーマンスに影響することは少ないが、大きいXMLを扱うアプリケーションをチューニングする場合や、数msを気にするチューニングの場合はこのような手段でGCのチューニングができる。

*1:具体的には、文字列や整数のような循環参照を作らないオブジェクトは除く、循環参照を作り得るオブジェクトだけを管理している

*2:Python3.4までは __del__ メソッドがあるだけで回収できなかった

logger のパフォーマンスについて [Go]

Go の logging ライブラリ、今はとりあえず標準ライブラリの loggoogle/glog を使っているんだけど、 log は機能不足が、 glogGoogle 標準ライブラリなのでフォーマットを調整したり flag 以外でカスタマイズするために fork しないといけなかったりするので、他のロギングライブラリどれが良いか時々話題の logger を覗いてみていた。

結果、もう logrus で良いかなーと思っているんだけどそれはいったん置いておいて、選定基準の1つのパフォーマンスについて書いておく。

logger のパフォーマンスで一番重要なのは、ログを書かない時のパフォーマンスだ。 デバッグ用の詳細なログをいたるところに散りばめているアプリケーションでも、ログレベルを WARN にしておけば、 DEBUG レベルのログがパフォーマンスに与える影響を最低限にできるべきだ。

そのために理想的な logger の持つべき機能をまとめてみる

handler だけででなく logger が level を持つ

高機能なログライブラリは大抵、 logger が record を作成し、 handler に渡すという構成になっている。 handler はログを設定された通りにフォーマットしてテキストファイルに出力したり、エラーがあったらメールを送信したりする。

handler によって処理が異なるため、ログに必要な情報を全てまとめたものが record になる。 リッチな logger だと record がトレースバックなどの取得にコストがかかる情報を持っていたりするが、それが handler に利用されないと無駄になる。

logger が level を持つことで、 record の生成自体をスキップして、高コストな情報の取得を避けたり、不要オブジェクトの生成をせずにGCに優しくなることができる。

logger の level は必ずしも handler と別に設定できる必要はない。例えば handler の level を変更不可能にすれば、 logger に handler を追加するAPIで、 logger に登録されている handler の中で一番詳細なログレベルを logger の level にすることができる。

呼び出し側が、呼び出し自体を避けやすくする

「高コストな情報取得」はトレースバックだけではない。ログの中に含める情報の取得にコストがかかるケースも有る。

その場合、 logger の呼び出し自体を、 logger のログレベルで回避する必要が出てくる。 glog は type bool Verbose を使って上手くこれを実装している。

glog.V(2).Infof("val: %v", veryHeavy())  // ログが書かれない場合も veryHeavy() が呼ばれる

if glog.V(2) {
    glog.Infof("val: %v", veryHeavy())  // これなら呼ばれない。
}

logrus も、glog ほどかっこ良くも無ければ効率も良くない (mutex 使ってるので) が、呼び出し自体のショートカットが可能だ。

if logrus.GetLevel() >= logrus.DebugLevel {
    logrus.Debugf("val: %v", veryHeavy())
}

structured logging について

最近は structured logging が人気だ。通常のテキストログが printf 形式でログのメッセージの中に値を直接埋め込むのに対して、 structured logging ではメッセージは固定文字列にして付加情報として key: value を持つ。

これは、ログをJSONやLTSVなどの形式にして後から利用しやすいという利点もあるし、出力形式が文字列フォーマットよりも軽い場合はテキストログに比べて性能が上がるケースも有る。

でも大抵の場合、何らかのデータ構造へのシリアライズはテキストフォーマットより重くなりがちだし、しかも logger に任意の key: value 形式の値を渡すためにたくさんの一時オブジェクトを利用してしまう (GCへの負荷が増える) ことになりがちだ。

logrus の structured logging は次のような API になっている。

  log.WithFields(log.Fields{
    "animal": "walrus",
    "size":   10,
  }).Info("A group of walrus emerges from the ocean")

log.Fields は map[string]interface{} なので、このAPIだと (Info() が出力されない場合ですら) map が生成されてしまうし、しかも interface{} に入った value 側の変数は全部エスケープ解析でエスケープすると判断される可能性が高く、本来ヒープアロケートが一切必要ないようなローカル変数ですらヒープにアロケートされてGCに負荷をかけてしまうだろう。

mgutz/logxi はより効率の良い structured logging の API を提供している。

log.Info("A group of wairus emerges from the ocean", "animal", "walrus", "size", 10)

プログラムのミスで key: value が逆になってしまう可能性に目を瞑れば、短くて効率がいい。 logrus に比べるとあまり使われてないし機能も少ないけど、性能と structured logging が絶対に必要であれば一考に値する。

X250のLCD交換

情報がまだ少なくて不安もあったけど、無事 X250 のLCD交換に成功しました。

  1. ヤフオクで国内業者から、 X240用の FullHD IPS 液晶パネルを購入。 LP125WF2 (SP)(B1) という液晶が届く。
  2. ベゼルの下側を爪で少し浮かし、あとは指で引っ張ってベキベキ外す。
  3. 液晶パネルを取り外して裏返してキーボード上に置く
  4. ケーブルを覆ってるテープを剥がしてケーブルを外す。
  5. 液晶を載せ替える。
  6. ケーブルを刺しテープを貼り直す。
  7. そのまま逆の手順で組み立て。

ドライバーを一切使うことなく、テープを剥がすのにカッターナイフの先を使ったけどそれもたぶん爪でできたってくらい手軽でした。裏側のカバーを外してSSD換装するよりも楽だったかもしれないくらい。

まだまだ MacBookRetina ディスプレイに比べると表示品質は全然ダメですが、使いものにならないくらいダメって事はなくなった。

でも、バッテリー消費が激しくなってしまった気がする。ふるさと納税X250には後部(交換可能)バッテリーが一番小さいやつが入ってるだけなので、安く入手可能なら内蔵の前部バッテリーも追加したい。 でもその前に非 MacBook で生きていけるか確認してみないと。

Thinkpad X250 に Ubuntu デュアルブートで遊ぶ

ふるさと納税で X250 が数量限定、寄付額13万円で出ていたので衝動買いしてしまいました。 CPUはかろうじて Celeron ではなく Core i3 ですが、メモリ4GB, WiFi は 5GHz 未対応、500GB HDD、液晶は TN の HD (縦768px) というスペックです。

HDDは256GBのSamsungのV-NAND使ったSSDに換装して、 Windows 10 と Ubuntuデュアルブート化し、 Ubuntu 15.04 メインで遊んでみます。 (15.04 からタッチパッド周りが改善されているようなので 14.04LTS より最新のバージョンを選びました)

/boot は ext4 にしような

あとで Windows を消して拡張しやすいように、 Windows の直後を / にし、その後ろに 10GB の swap を残すというパーティション構成にしました。 swap が 10GB なのは、ハイバネートに swap があった方が良かった記憶があって、将来メモリを8GBに増設する可能性を考えて余裕を持って 10GB にしました。

インストール時にちょっと遊びココロで / を xfs にしてみたのですが、 grub のシェルが立ち上がります。 シェルの使い方全くわからないままなんとかググって対処してみたところ、 grub が xfs を読めないみたいです。 あきらめて ext4 で再インストールしたら今度はなんの問題もなくデュアルブートできました。

一応 BIOSの設定画面でセキュアブートは切っていましたが、それ以外は特に問題なく。 Mac 以外でEFIブートが初めてだったけどチョロい。

バッテリーを長持ちさせたい

リチウムイオン電池は満充電だと寿命が縮むのは常識ですよね。 ThinkPad も昔から満充電しない制御を設定可能で、しかも Linux からも設定できるのがメリットでした。

それが、なんか Windows 10 だとそのツールがもう存在しなくて、設定できなくなっていました。 ググったところ、 Store アプリの古いバージョンで可能みたいですが、古いバージョンを探してインストールするのダルいし、 Windows はあまり使う気がないので放置。

Ubuntu では tp-smapi-dkms をインストールすればいいやと思っていたら、どうもカーネルモジュールがロードできない。 ぐぐってみたところ、今は TLP という電力管理ツールで設定が可能なようです。Ubuntu 15.04 は ppa:linrunner/tlp を使って、 15.10 からは標準で利用可能なようです。

その他感想

今時 WiFi が 5GHz 対応してないのも完全に予想外でしたが、何よりキツイのが液晶。明るさが足りないのかコントラストが足りないのかわからないけど、白地に黒文字のときに、暗いプロジェクターで映された文字を目を凝らして読んでいるような、古い液晶を使っているような感覚です。 黒字に白文字だとあまり気にならなくなるので、ターミナルに閉じこもってハックする効率は上がるかも?

あと、せっかく横幅あるのに、キーボードの左右にスペースがあってキーボード自体は @ や [ キーが細くなってるのがもったいない。Mac と行き来するときに感覚が狂って良くない。

試しに PC DEPOT の中古買取の見積もり出してみたら新品の場合の上限が21k円と言われてしまったので、X250の液晶交換の情報がもっと増えてきたらIPS液晶への換装を試してみます。

そこそこ薄くコンパクトになのに、 9mm の HDD が載せられたり、 HDD + M.2 SSD のデュアルディスクができたり、メモリ交換できたり、液晶交換もできそうだったり、なかなか面白いノートPCです。

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