読者です 読者をやめる 読者になる 読者になる

`pip install -e` でインストールしたpython製のコマンドの起動が異様に遅かった話

インストールされているコマンドの実行が遅い

pip install -e でインストールしたpython製のコマンドの起動が異様に遅かったということがあった。具体的には最近作っていたものなのだけれど。以下の2つはどちらも同じ挙動を示す。

# echo '{"hello": "world"}' > hello.json
$ zenmai hello.json
$ python -m zenmai hello.json

ところが実際に実行してみると python -m 経由で実行したものに比べて、コマンドで実行したものが異様に遅い。どれ位遅いのかというとこのくらい遅い。

time zenmai hello.json
hello: world
        1.28 real         0.78 user         0.19 sys
time python -m zenmai hello.json
hello: world
        0.29 real         0.23 user         0.04 sys
time zenmai hello.json
hello: world
        0.75 real         0.63 user         0.10 sys
time python -m zenmai hello.json
hello: world
        0.26 real         0.21 user         0.04 sys

通常は0.2~0.3sで終わるところが0.7~1.3s程度かかっている(おそらく2回目の実行はページキャッシュ的なものが効いているので早い)。

tl;dr

詳細に興味がない人用の要約

  • pip install -e . など特定の方法でインストールされたコマンドの読み込みが遅い
  • 原因は import pkg_resources(importされたタイミングでimport pathを走査する)
  • wheel経由でインストールした場合などは大丈夫

遅い原因

遅い原因を調べる前にインストールされたコマンドがどのような形になっているかを確認しておく。

$ which zenmai
/Users/podhmo/venvs/my3/bin/zenmai

ここでbin/zenmaiは以下の様なものだった。

#!/Users/podhmo/venvs/my3/bin/python
# EASY-INSTALL-ENTRY-SCRIPT: 'zenmai','console_scripts','zenmai'
__requires__ = 'zenmai'
import re
import sys
from pkg_resources import load_entry_point

if __name__ == '__main__':
    sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0])
    sys.exit(
        load_entry_point('zenmai', 'console_scripts', 'zenmai')()
    )

テキトウにslowなどの単語を入れて調べていたところ以下のissueを見つけた。

まさにこれが原因だった。内容を要約すると以下の様なもの。

  • 特定の方法でインストールされた(後述)コマンドの実行が遅い
  • 遅い理由は、実行毎に全てのimport pathを走査しているせい
  • 原因はpkg_resourcesのimport

特定の方法でインストールされたと言うのは、例えば pip install -e . などでインストールされた物を指す。そしてsetup.pyでsetuptoolsのentrypoint経由でコマンド化されたものを指す。

具体的にいうと、setup.pyに以下のような記述があるもの。

setup(name='zenmai',
      version='0.3.0',
      packages=find_packages(exclude=["zenmai.tests"]),
      install_requires=install_requires,
      entry_points="""
      [console_scripts]
      zenmai=zenmai.cmd:main
""")

entry_pointsにconsole_scriptsを指定する形でコマンドになるもの。

pkg_resources.load_entry_point の挙動

もう少し詳しく pkg_resources.load_entry_point の挙動を見ていく。

順を追って説明すると、以下の様なコードになっている。

  1. load_entry_point() が呼ばれる
  2. get_distribution() が呼ばれる
  3. get_provider()が呼ばれる。ここでworking_set(実はglobal変数)からdistributionを探してくる
  4. 見つけたdistributionのload_entry_point()が呼ばれる

(詳細は以下のコード参照)

def load_entry_point(dist, group, name):
    """Return `name` entry point of `group` for `dist` or raise ImportError"""
    return get_distribution(dist).load_entry_point(group, name)

def get_distribution(dist):
    """Return a current distribution object for a Requirement or string"""
    if isinstance(dist, six.string_types):
        dist = Requirement.parse(dist)
    if isinstance(dist, Requirement):
        dist = get_provider(dist)  # ここに入る
    if not isinstance(dist, Distribution):
        raise TypeError("Expected string, Requirement, or Distribution", dist)
    return dist

def get_provider(moduleOrReq):
    """Return an IResourceProvider for the named module or requirement"""
    if isinstance(moduleOrReq, Requirement):
        # ここのworking_setが問題
        return working_set.find(moduleOrReq) or require(str(moduleOrReq))[0]
    try:
        module = sys.modules[moduleOrReq]
    except KeyError:
        __import__(moduleOrReq)
        module = sys.modules[moduleOrReq]
    loader = getattr(module, '__loader__', None)
    return _find_adapter(_provider_factories, loader)(module)

class Distribution(object):
    # ...snip
    def load_entry_point(self, group, name):
        """Return the `name` entry point of `group` or raise ImportError"""
        ep = self.get_entry_info(group, name)
        if ep is None:
            raise ImportError("Entry point %r not found" % ((group, name),))
        return ep.load()

問題はworking_setの部分。これは、distribution(概ねpackage情報を格納するオブジェクト程度の認識でOK)の情報をキャッシュしておくpoolみたいな認識で良い。問題はpkg_resources moduleがimportされたタイミングで全ての情報をcacheしようと動いてしまい。それがしかもほとんど全走査になってしまっていること。

具体的には以下の部分でadd_entry()が呼ばれまくる(_call_asideは関数の実行+定義)。

# from jaraco.functools 1.3
def _call_aside(f, *args, **kwargs):
    f(*args, **kwargs)
    return f


@_call_aside
def _initialize_master_working_set():
    """
    Prepare the master working set and make the ``require()``
    API available.

    This function has explicit effects on the global state
    of pkg_resources. It is intended to be invoked once at
    the initialization of this module.

    Invocation by other packages is unsupported and done
    at their own risk.
    """
    working_set = WorkingSet._build_master()
    _declare_state('object', working_set=working_set)

    require = working_set.require
    iter_entry_points = working_set.iter_entry_points
    add_activation_listener = working_set.subscribe
    run_script = working_set.run_script
    # backward compatibility
    run_main = run_script
    # Activate all distributions already on sys.path with replace=False and
    # ensure that all distributions added to the working set in the future
    # (e.g. by calling ``require()``) will get activated as well,
    # with higher priority (replace=True).
    tuple(
        dist.activate(replace=False)
        for dist in working_set
    )
    add_activation_listener(lambda dist: dist.activate(replace=True), existing=False)
    working_set.entries = []
    # match order
    list(map(working_set.add_entry, sys.path))  # ここでたくさんのファイルを見る
    globals().update(locals())

そんなわけで。実行がおそい。非常におそい。そもそもコマンドの実行が遅いというわけではなく、 pkg_resources のモジュールを読み込むだけで遅い(これに付随してrequestsをimportしただけで遅くなるみたいな不具合があったりした(具体的には、import requests -> urllib3 -> cryptography -> pkg_resourcesという依存)。今は解消されている)。

結構ひどいくらい遅い。

time python -c 'import pkg_resources'

real    0m0.766s
user    0m0.602s
sys     0m0.088s

牧歌的な時代に作られたコードが後々辛くなるみたいな話はけっこう至る所に存在するみたいな話ではあったりするのかもしれない。

コマンドの起動が遅い問題の対応策

コマンドの起動が遅い問題の対応策は幾つかある。いずれも対症療法的といえば対症療法的で完全な解決ではない。(完全な解決といえばpkg_resourcesへの依存を完全に捨てるだったり。pkg_resources自体を直すだったりするのだけれど。後々理由は明らかにするけれど、あんまりやる旨味はないかもしれない)

dispatchをインストール時に変更するmonkey patch

そもそもコマンドの実行時に行われるdispatchをインストール時に行うようにすれば良いという発想で、setuptoolsに対するmonkey patchを行うコードを提供してくれた人が居る。

github.com

これをMANIFEST.inに含めるようにしつつ。setup.pyで import fastentrypoints を読み込むようにすると、スクリプトを書き込む部分にpatchをあてて以下のようなコードでインストールするように書き換えるもの。

# -*- coding: utf-8 -*-
import re
import sys

from zenmai.cmd import main

if __name__ == '__main__':
  sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0])
  sys.exit(main())

確かにインストール後のコマンドからpkg_resourcesへの参照が消えるので早くはなる。とは言え、全てのpackageに対してこれを組み込むのはなんというかだるい。

そもそもwheelでインストールした場合は大丈夫

ちなみにwheel形式で提供されているパッケージの場合は pkg_resources を参照する形でインストールされないので今回のような不具合は発生しない。

# zenmaiはwheel形式でpackageが提供されている
$ pip install zenmai
Collecting zenmai
  Using cached zenmai-0.3.0-py2.py3-none-any.whl
$ cat `which zenmai`
#!/private/tmp/foo/bin/python3.5

# -*- coding: utf-8 -*-
import re
import sys

from zenmai.cmd import main

if __name__ == '__main__':
    sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0])
    sys.exit(main())

そろそろpackageの配信もwheelで提供されていることがほとんどだろうし。ふつうに使う分にはあんまり問題にならない。これがあんまりpkg_resourcesの改善を頑張ろうみたいな方向に力を注ぐ意義があんまり感じられない理由(どちらかと言うと、pkg_resourcesの依存を無くしていくという試みの方が良さそう)。

ちなみに当然ではあるけれど。 pip install --no-binary :all: zenmai などとしてあえてインストールした場合には同様の問題が発生する。

なぜ、pip install -e . したくなるのか

本題とは外れるけれど。なぜ、pip install -e . したくなるのかというと、通常のinstallの場合には lib/puthon<version>/site-packages/<package name> 以下にファイルがコピーされてしまうので、開発時にこうなってしまうとわりと面倒くさい。なので自分で作っているパッケージなどはついつい pip install -e . でインストールしてそのまま作業という形にしてしまいがち。

setuptoolsのentrypointsを使わない選択肢

そもそもentrypointsのhookを利用してコマンドをインストールしようとしていたから良くないので、これを避ければ大丈夫のはず。scriptsでinstallするという方法もある。と思ったけれど。scriptsの方で書いてもpkg_resourcesが参照されるような形になってしまった。何か勘違いしているかもしれない(要調査)。

#!/Users/podhmo/venvs/my3/bin/python
# EASY-INSTALL-DEV-SCRIPT: 'zenmai==0.3.0','myzenmai'
__requires__ = 'zenmai==0.3.0'
__import__('pkg_resources').require('zenmai==0.3.0')
__file__ = '/Users/nao/vboxshare/venvs/my3/zenmai/scripts/myzenmai'
exec(compile(open(__file__).read(), __file__, 'exec'))

まとめ

setuptools経由でインストールしたコマンドの実行が異様に遅い。遅い理由は pkg_resources のimportのせい(というかpkg_resourcesの実装がイケていない)。とは言え、ふつうの人はwheel形式でpackageがインストールされると思うし。wheel形式でインストールされた場合にはこの症状が発生しないので。ふつうのひとはあんまり被害がない(というか、packageの作成者はwheel形式でpackageを提供しましょう)みたいな話しでおしまい。