Python 3.11 からデフォルトの文字列ハッシュアルゴリズムが SipHash13 になります

Pythonの文字列やバイト列に対するハッシュアルゴリズムは、HashDoS対策としてPython 3.4から SipHash24が使われていました。

その後、ラウンド数を減らしたSipHash13でも十分に安全だとして2015年にRustが、2016年にRubyが、SipHash24からSipHash13への切り替えを行いました。

Python でもSipHash13に切り替えようという提案を2017年に行っていたのですが、実装した人がなかなかプルリクエストを作ってくれず、またPythonは文字列がimmutableでハッシュ値をキャッシュしているためにそこまで大きなインパクトがなかったこともあり、ずっと放置されていました。

Issue 29410: Moving to SipHash-1-3 - Python tracker

しかし、Python の高速化プロジェクト Faster CPython で、セキュリティをある程度犠牲にしても高速なハッシュアルゴリズムを使うことで起動速度を上げられないかという話題が持ち上がり、まずは SipHash13 の切り替えを前に進めることにしました。

Faster hash function · Issue #88 · faster-cpython/ideas · GitHub

ということで、 Rust や Ruby からは数年遅れましたが、Pythonもデフォルトの文字列ハッシュアルゴリズムがSipHash13に切り替わりました。

他のハッシュアルゴリズムを使いたいときは、 configure のオプションの --with-hash-algorithm=[fnv|siphash13|siphash24] で切り替えることができます。

`from __future__ import annotations` がPython 3.10でデフォルトにならなくなりました

PEP 563 は Python 3.10 でデフォルトになる予定で、実際に去年の10月から master ブランチでは有効になっていました。今までの Python 3.10 のアルファ版でも有効になっています。

www.python.org

このPEPはアノテーションの実行時の利用に後方非互換性と大幅な制限を加えてしまいます。

>>> class C:
...     a: int
...
>>> C.__annotations__
{'a': <class 'int'>}

この例では __annotations__ に int クラスのオブジェクトそのものが入っていますが、PEP 563が有効になると "int" という文字列が入るので、直接 __annotations__ を見ていたコードは動かなくなります。

PEP 563 が有効でなくてもPEP 484に従ったtype annotationでは、アノテーションが評価されるタイミングでは解決できない名前を a: "int" のように文字列で書くことでアノテーション利用時に評価していたので、それだけで動かなくなるコードはもともと不完全ともいえるのですが、 PEP 484 にはあまり関心がなく前方参照が無いアノテーションを前提にして書かれていたコードは動かなくなります。

また、名前解決のスコープの問題もあります。

>>> def f(t):
...     class C:
...         a: t
...     return C
...
>>> kls = f(str)
>>> kls.__annotations__
{'a': <class 'str'>}

この例では、PEP 563では kls.__annotations__{'a': 't'} になりますが、 typing.get_type_hints(kls) してもすでに t はモジュールのスコープにないので解決できません。

この例はもう完全にstatic typingが動かないのでPEP 563がAcceptされた時点では妥当な制限だったのですが、最近流行しているFastAPIが利用しているPydanticというライブラリがアノテーションを実行時の型情報を扱うために利用しており、実際にユーザーの中には上の例のようなコードを書いてしまっている人が居るようです。

PEP 563がAcceptされたのが2017年12月で、PydanticやFastAPIが盛り上がったのは完全にその後なのでこの状況は非常に残念なのですが、上のような利用法に対して適切にwarningを出す仕組みが存在しない(仕組み上出すのが非常に難しい)ので仕方ありません。

Pydanticは上のような利用法でも使えるようにする仕組み update_forward_refs() を提供しては居るのですが、その仕組みを明示的に呼び出していないユーザーのコードは壊れます。

Postponed annotations - pydantic

また、文字列化されたアノテーションPythonオブジェクトに変換するために eval() を使うのですがそれが遅いという問題もあります。

どの問題も致命的ではないものの、特にFastAPIのコミュニティーが移行の準備が全くできていないということで、PEP 563のデフォルト化は延期されることになりました。現在デフォルト化を取り消すプルリクエストがレビュー中です。

github.com

また、PEP 563の欠点のいくつかを改善するPEP 649も提案されています。アノテーションの今後については、Python 3.10が落ち着いてから慎重に議論されるでしょう。

なお、去年このBlogで紹介した PEP 563 向けの最適化はrevertされずにそのまま残ります。なので from __future__ import annotations を書いているユーザーは起動速度やメモリ使用量のオーバーヘッドが大幅に低減されます。

methane.hatenablog.jp

また単純な revert ではなく、この最適化を生かした形で from __future__ import annotations が書かれていない場合の実装が改めて書かれています。関数アノテーションfrom __future__ import annotations が書かれていなくても一旦タプルとして保存され、 func.__annotations__ にアクセスされた時にタプルからdictに変換されます。

多くはないですが、 def __init__(self) -> None: のようなアノテーションならPEP 563がなくても ('return', None) というタプルになり、これは定数 (int などは名前解決しないといけないので定数ではない) なので、コンパイル時にタプルが作られた上で、同じモジュールないの同じシグネチャでは同一のタプルオブジェクトを使い回せるという最適化も効いてきます。

gzip, bz2, lzma の binary mode でのイテレーションを高速化しました。

次の2つのプログラムは、どちらも test.gz というファイルを開いて、片方はバイナリモードで、もう片方はテキストモードでイテレートします。Pythonの使用上ファイルをイテレートしたら行単位で内容を読み出せます。

$ cat dec_gzip.py
import gzip
with gzip.open("test.gz", 'rb') as f:  # バイナリモード
    for l in f:
        pass

$ cat dec_gzip_t.py
import gzip
with gzip.open("test.gz", 'rt', encoding="utf-8") as f:  # テキストモード
    for l in f:
        pass

この2つのプログラム、どちらが速いと思いますか?僕はバイナリモードの方だと思っていました。 TextIOWrapper() の処理がまるまるオーバーヘッドになるはずだからです。

$ python3 -V
Python 3.9.1

$ time python3 dec_gzip.py

real    0m0.528s
user    0m0.508s
sys     0m0.021s

$ time python3 dec_gzip_t.py

real    0m0.285s
user    0m0.280s
sys     0m0.007s

なんと、バイナリモードの方が2倍くらい遅いのです。

バイナリモードで gzip.open() すると GzipFile というオブジェクトが返されます。この __iter__ メソッドは親クラスの IOBase.__iter__ で、Cで実装されているのですが、振る舞いとしては毎回 self.readline() を呼び出してそれを返します。そして GzipFile.readline()Python で実装されているのです。

    def readline(self, size=-1):
        self._check_not_closed()
        return self._buffer.readline(size)

一方テキストモードで開いた場合は、 GzipFile を TextIOWrapper でラップしたオブジェクトが返されます。 TextIOWrapper.__iter__IOBase.__iter__ なのですが、 TextIOWrapper.readline() が C で実装されているのと、TextIOWrapperがある程度バッファリングしていて毎回 GzipFile.read() を呼び出さなくて良いので速いのです。

bz2 モジュールの場合はさらにこの差が広がります。 gziplzma モジュールはスレッドセーフではないのですが、 bz2 モジュールの BZ2File だけは複数スレッドからの並行な read/write が許可されていたので、行ごとに毎回呼び出されるPythonで実装された readline メソッドの中で with self._lock しているのです。

$ time python3 dec_bz2.py

real    0m1.657s
user    0m1.655s
sys     0m0.004s

$ time python3 dec_bz2_t.py

real    0m0.868s
user    0m0.851s
sys     0m0.016s

bz2の方がgzipよりも圧倒的にデコード負荷が高いために比率で見たら同じ程度の速度低下で済んでいますが、差(オーバーヘッドの大きさ)を考えると、同じ100万行を処理するのにかかるオーバーヘッドが0.22sec程度から0.8sec程度に増えています。

これを Python 3.10 で次のように修正しました。

  • bz2 はスレッドセーフをやめる。 (issue43785)
  • GzipFile, BZ2File, LZMAFile で __iter__ をオーバーライドし、内部で使っているバッファ (io.BufferedReader) の __iter__ に移譲することで、毎回Python実装された readline() が呼ばれないようにする。 (Add iter to GzipFile, BZ2File, and LZMAFile #25353)

結果、バイナリモードでのイテレートがPython 3.9より倍以上速くなり、テキストモードよりも少し速くなりました。

$ time ./python dec_gzip.py

real    0m0.198s   # old: 0.528s, text: 0.285s
user    0m0.195s
sys     0m0.005s

$ time ./python dec_bz2.py

real    0m0.771s  # old: 1.657s, text: 0.868s
user    0m0.758s
sys     0m0.015s

ちなみに、Python 3.9 でバイナリモードで行単位の処理をしたい場合は、プライベートメンバーに触ってしまいますが次のようにすれば同じ高速化ができます。

with gzip.open(filename) as f:
    for l in f._buffer:
        pass

staticmethod が callable になりました

github.com

Pythonで定義した関数とCで定義した関数は型が異なり、振る舞いにも幾らかの違いがあります。これがPython実装とC実装の両方を提供する場合に、挙動の一貫性がないという問題になります。

今回問題になったのは、Cで定義した関数は勝手にメソッドにはならないという点です。

>>> def mylen(x): return len(x)
...
>>> class C:
...     len = len
...     mylen = mylen
...
>>> C().len([])
0
>>> C().mylen([])
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: mylen() takes 1 positional argument but 2 were given

Python で定義した mylen(x) をクラス変数に代入してインスタンスの属性として呼び出すと、自動的にメソッド扱いされてCのインスタンスx に渡されてしまいます。

インスタンスメソッドやクラスメソッドにしないように @staticmethod を使うと良さそうですが、Python 3.9まで staticmethod はただのディスクリプタで、クラスやインスタンス経由で利用しないといけませんでした。

>>> @staticmethod
... def mylen(x): return len(x)
...
>>> class C:
...     mylen = mylen
...
>>> C().mylen([])
0
>>> mylen([])
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'staticmethod' object is not callable

これが先ほどのコミットで変更され、 staticmethod がそのまま呼び出せるようになりました。

Python 3.10.0a7+ (heads/master:553ee2781a, Apr 12 2021, 13:46:28) [Clang 12.0.0 (clang-1200.0.32.29)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> @staticmethod
... def mylen(x): return len(x)
...
>>> class C:
...     mylen = mylen
...
>>> C().mylen([])
0
>>> mylen([])
0

Python 3.10a7 はリリース済みなので、この変更が反映されるのは次の Python 3.10b1 からになります。

PYTHONWARNDEFAULTENCODINGを使おう

methane.hatenablog.jp

この記事で紹介した、 open() などでエンコーディングを指定せずに暗黙でデフォルトのエンコーディングが使われた時に EncodingWarning を発生させる機能のPEPが受理され、実装し、昨晩リリースされた Python 3.10a7 に入りました。

.bashrc などで extern PYTHONDEFAULTENCODING=1 などとしておけば、デフォルトエンコーディングを使用している箇所に警告が表示されます。自分で書いているプログラムだけでなく、利用しているツールやライブラリ、フレームワークに対する警告についても見かけたら開発元に報告してもらえると助かります。

警告を見つけた場合にどうするべきか、簡単に説明します。

UTF-8を利用している場合

MarkdownYAML、TOML、JSONを開くのに encoding 引数を指定しないのは、ほとんどの場合バグです。特定の環境で動けば良いプログラムならそのままでも大丈夫ですが、そうでない場合はWindowsでファイルに非ASCII文字が含まれているとエラーになります。 encoding="utf-8" を指定しましょう。

ロケールエンコーディングを利用している場合

Python 3.10 からは encoding="locale" を指定して Warning を解消することができますが、これをすると Python 3.9 で動かなくなります。 Python 3.9 にも対応した形でWarningを解消したい場合は encoding=locale.getpreferredencoding(False) を指定することができますが、そのまま放置しても問題ないでしょう。

将来的にデフォルトのエンコーディングUTF-8に切り替わった場合に動かなくなりますが、近い将来に切り替わる予定はありません。数年後に Python 3.9 のサポートを切ってから encoding="locale" を指定しましょう。

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