`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を見つけた。
- Avoid full path enumeration on import of setuptools or pkg_resources? · Issue #510 · pypa/setuptools
まさにこれが原因だった。内容を要約すると以下の様なもの。
- 特定の方法でインストールされた(後述)コマンドの実行が遅い
- 遅い理由は、実行毎に全ての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 の挙動を見ていく。
順を追って説明すると、以下の様なコードになっている。
- load_entry_point() が呼ばれる
- get_distribution() が呼ばれる
- get_provider()が呼ばれる。ここでworking_set(実はglobal変数)からdistributionを探してくる
- 見つけた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を行うコードを提供してくれた人が居る。
これを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を提供しましょう)みたいな話しでおしまい。