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" を指定しましょう。

パターンマッチングがAcceptされました

Mailman 3 [python-committers] Acceptance of Pattern Matching PEPs 634, 635, 636, Rejection of PEPs 640 and 642 - python-committers - python.org

パターンマッチング構文が複数提案されていたのですが、 PEP 634, 635, 636 が Accept されました。順調にいけば今年リリース予定のPython 3.10に搭載されるでしょう。

大きい提案なのでPEPが3つに分けられています。

興味がある人はチュートリアルだけ読めば使い方がわかるようになっています。

PythonのデフォルトエンコーディングをUTF-8にするために

Python がテキストファイルを開く時のデフォルトエンコーディングUTF-8でないことは、多くのWindowsユーザー、特にプログラミング初心者にとって障害になっています。

UnicodeDecodeError で検索すると、多くのWindowsユーザーが問題に遭遇しているのがわかります。

エラーの内容を見てみると、次のようなケースが多いようです。

  • JSONなどのUTF-8で書かれたテキストファイルを開こうとして UnicodeDecodeError が発生する
  • Webから取得したテキストデータなどを保存しようとして UnicodeEncodeError が発生する
  • pip install しようとしたパッケージが setup.py の中で、UTF-8で書かれた README.md や LICENSE ファイルを読んでいる

これらのエラーは、Pythonがデフォルトで利用するエンコーディングUTF-8にすると解決します。

しかし、これは後方互換性を失う変更になります。後方互換性のためのオプションを残しつつ変更しようという提案をしたことがあるのですが、Microsoft所属のコミッターであるSteve Dower氏から強い反対を受けました。数年間、 encoding= を指定していない全ての open() などの呼び出しに、デフォルトが変わるというWarningを発生させるべきだと言うのです。

Warningに対する反対意見もあります。ほとんどの encoding= 指定がない open() は、ASCIIのみのファイルを開いていたり、クラスプラットフォームの必要がないケースだったりです。DeprecationWarningならほとんどの場合デフォルトで表示されないのですが、それでもDeprecationWarningが大量発生すると他のDeprecationWarningが巻き添えで無視されるケースが出てくるので良くありません。

Warningを出すべきという意見と嫌だと言う両方の意見の板挟みになり、このままでは前に進めません。どうしたら良いでしょうか?

戦略1: opt-in な Warning

大きな問題は、DeprecationWarningを出すのがどれくらいウザいのかです。そこで opt-in でEncodingWarningを出す提案PEP 597を出しています。だいぶ話がまとまってきたので早ければ今週中にもSteering Councilに提出しようと思っています。

このオプションを有効にして、標準ライブラリやpipのような誰もが使うツールから不適切な encoding= の省略を潰していけば、いつかはこのWarningをデフォルトで出せるようになるかもしれません。うまくいけばその数年後にデフォルトのエンコーディングを変更できるでしょう。

しかし、この戦略には大きな欠点があります。デフォルトのエンコーディングを変更できるのがいつになるかわかりません。少なくとも5年以上は先になるでしょう。2030年に間に合わないかもしれません。

Windows上でPythonを使う多くの新しいユーザーを、5年以上もこの不幸な状況に留めておくのは良くありません。もっと素早い解決方法が必要です。

戦略2: UTF-8 mode を普及させる

UTF-8 mode を使うと最初に紹介した問題を解決することができます。ですが UTF-8 mode はまだあまり知られていません。

UTF-8 modeを有効にするには -Xutf8 オプションか PYTHONUTF8 環境変数を利用する必要があり、これは特にコマンドラインを普段使わないようなユーザーにとっては高いハードルになります。インストーラーやスタートメニューに登録される小さいツールなどで設定できるようユーザー環境変数を設定することができますが、それでも既存の Python を使ったアプリを壊してしまう懸念があり、そう簡単に誰にでもおすすめできるものではありません。

この問題を解決するために、環境(インストールや仮想環境)ごとにUTF-8 modeを有効にできる設定ファイルを追加しようというアイデアを現在 Python-ideas MLで提案しています。

まだ具体的なアイデアは固まっていませんが、例えば python.exe と同じディレクトリに python.ini ファイルを置き、その中に utf8mode=1 という行があればUTF-8 modeを有効にするというものです。

Pythonをインストールするときや仮想環境を作る時にUTF-8 modeを有効にするか選べるようにすれば、例えば授業で生徒にPython環境をセットアップしてもらうときにUTF-8 modeを有効にしてもらうことができるでしょう。

もう少し野心的な事を話すと、UTF-8 modeを有効にすることがWindows上でのPython環境のベストプラクティスと認識されるようになったら、インストーラーでこのオプションにデフォルトでチェックを入れることができるかもしれません。そうなれば、新しいユーザーにとっては実質的にUTF-8 modeがデフォルトになります。

初心者向けに Python の環境構築記事を書かれる方は、ぜひ UTF-8 mode を紹介する事を検討してみてください。

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