最近のPython-dev(2018-01)
バックナンバー:
Python 3.7b1 が29日の予定です。問題なければ日本時間の今日中に出るはずですが、buildbotやTravisが不安定なので少し遅れるかもしれません。これで feature freeze なので新機能追加やパフォーマンス向上などは基本的に終わりです。
beta 前の駆け込みで、先月号で紹介したAcceptされたPEPを実装する大型コミットがたくさんマージされました。(間に合わなかったものは無いはず。たぶん。)
今回は私が関係していたところを中心に紹介していきます。
dict の順序の言語仕様化
Python 3.6 から dict が挿入順序を保存するようになりましたが、この挙動は実装詳細であり、まだ言語仕様ではありませんでした。そのため、他の Python 実装で同じ挙動になることは保証されません。具体的には PyPy は同じ挙動(というかCPythonがPyPy実装を真似した)ですが、 MicroPython は違います。
この順序に依存しないように、内部では維持している順序をイテレート時にランダム化するような改造をするか、便利だしいっその事言語仕様にしてしまうかという話題があって、結果として 3.7 からは言語仕様になることになりました。
これに関連して、僕が collections.OrderedDict からリンクリストを削ってメモリ使用量を半減する実装を試していたのですが、挿入順を維持するためだけなら dict を使えるのでこの実装はディスコンになりました。
hasattr(), getattr() の高速化
https://github.com/python/cpython/pull/5173
hasattr(), getattr() は探索する属性が見つからない場合、内部で一度 AttributeError を発生してからそれを握りつぶして、 hasattr なら False, getattr なら第三引数に渡されたデフォルト値を返すという挙動をしていました。
この例外を発生させる+潰すの組み合わせが若干のオーバーヘッドになっているのでなんとかしたいのですが、すべてのケースで回避することはできません。 Python で __getattr__
を定義しているときにも AttributeError を投げるのが決まり(プロトコル)ですし、Cレベルでこれに相当する tp_getattro というスロットも同じプロトコルにもとづいています。
しかし、属性アクセスについて何らかのカスタマイズが不要な場合、 tp_getattro にはデフォルトで PyObject_GenericGetAttr() という関数ポインタが設定されています。 tp_getattro を呼び出す前に、それが PyObject_GenericGetAttr であれば、属性が見つからなかったことを例外を使わずに返す特殊化された関数を呼ぶようにしました。
これで、 getattr をカスタマイズしていない多くの型で getattr() や hasattr() が高速になりました。
例えば Django のテンプレートで hasattr(s, '__html__')
としているところがあり、かなりの回数で False を返すので、少し高速化できたことを確認しています。
私は hasattr(), getattr() だけを高速化していたのですが、さらに Serhiy さんが _PyObject_Lookup という名前の新プライベートAPIとして整備して、 Python 内部で PyObject_GetAttr() を呼んでから AttributeError を消していたすべての呼び出し元を新APIを使うように書き換えてくれました。
定数畳み込み
例えば 1 + 2
というコードがあったとき、1 と 2 という定数をスタックに積んでから BINARY_ADD するというバイトコードが一旦作られます。
その後、 peephole と呼ばれているバイトコードレベルの最適化で、連続する LOAD_CONST のあとに BINARY_ADD が来たときは、1 + 2 を計算してしまって結果の 3 を定数テーブルに登録し、1つの LOAD_CONST に置き換えるという最適化をしていました。これを定数畳み込みといいます。
しかし従来の方法には欠点がありました。畳み込み前にテーブルに入っていた定数が不要になるかもしれないのに、そのまま残ってしまっていたのです。次のサンプルコードでは、定数3しか利用していないのに、1と2がテーブルに残っている例です。 (なお、co_consts の最初にある None は docstring なので消せません)
>>> import dis >>> def x(): ... return 1+2 ... >>> x.__code__.co_consts (None, 1, 2, 3) >>> dis.dis(x) 2 0 LOAD_CONST 3 (3) 2 RETURN_VALUE
定数畳み込みのあとに定数テーブルを再構築するという最適化が提案されたこともありますが、定数テーブルを2回作り直すよりも定数畳み込みをバイトコード生成前のASTの段階でやってしまう方が筋が良いだろうということで却下されていました。
そしてASTレベルの定数畳み込みは、Eugene Toderさんが実装されていたのですが、それも何年も放置されていました。 それを見つけて今のバージョンで動くように手直して、 Python 3.7 に入りました。 Python 3.7 では上の例はこうなります。
>>> x.__code__.co_consts (None, 3) >>> dis.dis(x) 2 0 LOAD_CONST 1 (3) 2 RETURN_VALUE
TextIOWrapper の encoding を変更可能に
「Python 3.7 でテキストファイルのエンコーディングを初期化後に変更可能になります」 という記事で紹介したのでそちらを参照してください。
lru_cache の省メモリ化
functools.lru_cache
は dict と双方向リンクリストを使って LRU を実装しています。
dict と双方向リンクリストという組み合わせは OrderedDict と同じです。同じ実装が2種類あるのは無駄だなぁと思って一度 OrderedDict を使う形で書き直してみたのですが、パフォーマンスが落ちてしまいました。 OrderedDict は dict を継承しているしがらみが強くて、要素を削除する時などに内部で探索をなんどかやり直していることがあり、 dict + 双方向リンクリストとしての実装は lru_cache の内部実装の方が圧倒的に素直だったのです。
ということでこの試みは失敗したのですが、その際に lru_cache の双方向リストの各エントリがGC可能オブジェクトになっていることを発見しました。これにより、一要素ごとに3ワード (64bit マシンでは 24byte) のオーバーヘッドがかかり、GCの処理時間も長くなります。
Python の巡回参照GCを動作させるには、各エントリを個別に走査してもらわなくても lru_cache 本体が走査されるときにリンクリストを舐めて行けばすむということを思いつき、各エントリのGCヘッダを削除することができました。これでキャッシュされている要素数 * 24バイトの削減と、lru_cacheをヘビーに使っているアプリでのGC速度の向上が見込めます。
ABCのC実装
https://github.com/python/cpython/pull/5273
Python の起動時間を遅くする3大要因が ABC と Enum と正規表現です。(独断と偏見)
起動時にほぼ確実に import されるモジュールに _collections_abc があるのですが、これを見ると、たくさんの ABC が定義され、たくさんの型がそこに register されているのがわかります。
以前に紹介した importtime オプションで確認してみます。
$ local/py37/bin/python3 -Ximporttime -c 42 import time: self [us] | cumulative | imported package ... import time: 74 | 74 | _stat import time: 202 | 275 | stat import time: 166 | 166 | genericpath import time: 244 | 409 | posixpath import time: 1696 | 1696 | _collections_abc import time: 725 | 3105 | os import time: 218 | 218 | _sitebuiltins import time: 160 | 160 | _locale import time: 155 | 315 | _bootlocale import time: 500 | 500 | sitecustomize import time: 220 | 220 | usercustomize import time: 692 | 5047 | site
ABCの定義とregisterの両方を根本的に高速化するために、Ivan Levkivskyi さん (つづりが難しい) がC実装を作ってくれたのですが、色々問題があって協力して改善中です。
量としては多くないんだけどメタクラスと weakref を扱うので正しく実装するのが面倒なんですよね。。。
なんとか 3.7b1 までにマージしても問題なく動きそうなところまでは持っていったのですが、 3.7 のリリースマネージャーが b1 後にしても良いよと例外を認めてくれたので、一旦落ち着いて実装を磨き直す予定です。
これがマージされると、 Python の起動時間 (python -c 42
) はこんな感じになります。
バージョン | 起動時間(ms) |
---|---|
3.6 | 16.4 |
3.7b1 | 14.4 |
3.7b1 + C-ABC | 13.9 |
また、 python -c 'import asyncio'
の時間も、 Python 3.6, 3.7b1, C-ABC で 88.2ms -> 58.7ms -> 57.6ms と早くなって来ています。
(追記) str, bytes, bytearray に isascii() メソッドを追加
Python の str の isdigit() などのメソッドは Unicode 対応しているために非ASCII文字に対しても True を返してしまいます。また int() も Unicode 対応しています。
>>> s = "123" >>> print(ascii(s)) '\uff11\uff12\uff13' >>> s.isdigit() True >>> int(s) 123
しかし、ASCII文字だけを受け付けたいというケースもあるでしょう。その時に .encode('ascii') が例外を投げないかチェックするのは手間です。
そこで str.isascii() メソッドの追加を提案しました。これを使えば s.isascii() and s.isdigit()
で ASCII の整数だけが使われているかをチェックできます。
いろいろな意見はあったものの基本的に反対意見はなくて、Guidoがbytesとbytearrayにも同じメソッドを追加しておいてというメッセージで決まりました。
実装の話として、 str は内部で ASCII フラグを持っている (このフラグで UTF-8 へのエンコードがスキップできる) ので O(1) ですが、 bytes と bytearray はバイト列を舐めて最上位ビットが立っていないかチェックしているので文字列長nに対して O(n) になります。
(追記) PEP 567 -- Context Variables
引数を使わずに関数呼び出しを超えて状態を持ち回りたい事があります。
例えばWebアプリケーションだとしたら、ログを書く時に現在のリクエストのIDとかユーザーIDを残したいけれども、ログを書く全ての関数の引数にそれらを追加したくないなどです。
Python の標準ライブラリで言えば、 decimal モジュールが演算精度などをコンテキストとして管理しています。
こういった状態は、マルチスレッドではスレッドごとにコンテキストが異なるので、従来はスレッドローカル変数に格納していました。
しかし、 asyncio などの非同期フレームワークでは、複数のコンテキストが1つのスレッドで動くので、スレッドローカルでのコンテキスト管理がうまく行きません。そこでスレッドと独立したコンテキスト管理のためのライブラリが PEP 567 で追加されました。
これを使えば、各スレッドが「現在のコンテキスト」を持っているものの、スレッド内でコンテキストの切り替えが明示的にできるので、 asyncio だとイベントループが関数呼出しする毎にそのコンテキストを復元してやることができます。
MLでの議論は長すぎて私は見てなかったのですが、そろそろ Accept されそうだというところで依頼されて実装の方のレビューをしていました。
内部実装としては HAMT と呼ばれるアルゴリズムを利用していて、名前は聞いたことがあったもの詳細は知らなかったので、それを理解しながらC実装をレビューするのは楽しかったです。