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です。

VirtualBox 5 で利用可能になった Paravirtualization 機能 kvmclock を使う

Oracle に買収されてからずっと 4.x のまま大きな進化が無いのではないかと心配した時期もありましたが、とうとう VirtualBox 5 が出ましたね。 買収後は vagrant や、最近だと boot2docker などで重要度が大きく増しているので、今後の発展にも期待しています。

さて、 VirtualBox 5 の目玉の新機能といえば Paravirtualization 機能がいろいろな所で表に出てますね。これって何なんでしょう?

一般論を言えば、ホストマシンがハードウェアをエミュレートするのが HVM、ゲストマシンにAPIを提供するのが Paravirtualization (PVM) なので、CPU等の仮想化支援機能を使える場所を除いて一般的には PVM の方が高速化・効率化が可能です。

VirtualBox でも 4.x 時代からNICが virtio に対応していました。ネットワークインターフェイスの種類として virtio を利用すれば、実在するハードウェアのNICエミューレートするために無駄なコストを払う必要がなく、すこし軽くなるはずです。

ということは、 VirtualBox 5 で何か PVM のデバイスが追加されたはずです。(個人的にはブロックデバイスが virtio になって高速化されると嬉しいなと期待していました。) 調べてみたところ、ドキュメントにちゃんと載っていました。

10.4. Paravirtualization providers

KVM: Presents a Linux KVM hypervisor interface which is recognized by Linux kernels starting with version 2.6.25. VirtualBox's implementation currently supports paravirtualized clocks and SMP spinlocks. This provider is recommended for Linux guests.

Paravirtualization Provider に KVM を指定すると、 clock と spinlock の PVM が利用できるようです。

このうち clock については、過去に VirtualBox にマルチコアを割り当てると gettimeofday 等のシステムコールが非常に遅くなるという問題にぶつかったことがあり、最近でもよく EC2 などでマルチスレッドプログラムのベンチマークをするときは clocksource を xen から tsc に変更してるなど、個人的に非常に分かりやすく効果がありそうなので、試してみました。

まず、 Paravirtualization Provider に KVM を指定して (GUI では System > Accelaration にあります) Ubuntu 14.04.2 を起動します。

$ cat /sys/devices/system/clocksource/clocksource0/available_clocksource
kvm-clock tsc acpi_pm
$ cat /sys/devices/system/clocksource/clocksource0/current_clocksource
kvm-clock

kvm-clock が利用可能で、デフォルトで利用されていることが解ります。

次に、以下のプログラムで gettimeofday の速度を計測してみます。

#include <sys/time.h>
#include <stdio.h>

int main()
{
    struct timeval tv;
    for (int i=0; i<1000000; i++) {
        int r = gettimeofday(&tv, 0);
        if (r < 0) {
            perror("gettimeofday");
            return 1;
        }
    }
    return 0;
}
$ gcc -O2 -o t t.c -std=c99
$ time ./t
./t  0.05s user 0.00s system 99% cpu 0.058 total

非常に速いですね。 tsc のように、実際にはシステムコールを発行しないようです。 strace しても gettimeofday は確認できませんでした。

tsc と速度を比較してみます。

$ sudo sh -c "echo tsc > /sys/devices/system/clocksource/clocksource0/current_clocksource"
$ cat /sys/devices/system/clocksource/clocksource0/current_clocksource
tsc
$ time ./t
./t  0.02s user 0.00s system 95% cpu 0.023 total

流石に tsc よりは倍くらい遅いのかな。最後に acpi とも比較してみます。 VirtualBox 4 ではマルチコアだとすごい遅かった覚えがあるのですが、さて。

$ sudo sh -c "echo acpi_pm > /sys/devices/system/clocksource/clocksource0/current_clocksource"
$ cat /sys/devices/system/clocksource/clocksource0/current_clocksource
acpi_pm
$ time ./t
./t  0.00s user 1.64s system 99% cpu 1.645 total

gettimeofday 1回あたり 1.64μs. VirtualBox 4 で計測してなかったので比べられないけど、速くなってるかもしれません。

とはいえ、 kvm-clock と比べて30倍、 tsc と比べて80倍遅いので、 gettimeofday() 等のクロック系システムコールを多用するプログラムを動かす場合は避けたいところです。

クロックソースについてはよく知らないので思い込みですが、tsc がCPUのベースクロック(あるいはそれに類する、同期回路を動かすためのクロック) を利用している分、実時間を計測するには不安定(たぶん1日当たりの誤差は1000円の腕時計の方が小さい)なのに対して、 kvm-clock だと自動的にホスト側の時計に同期してくれることを期待しています。

tsc に比べたら倍くらい遅いと言っても、もともと tsc を使った時の gettimeofday が他のシステムコールに比べてタダ同然だったので、気にせず kvm-clock を使って置けば良さそう。

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