奇怪的 ~150ms 启动惩罚使用 python setuptools

strange ~150ms startup penalty using python setuptools

我一直在使用 python 的设置工具进行奇怪的 150 毫秒启动惩罚,我构建了一个最小的测试用例,但问题仍然存在:

这个最小案例的项目布局是:

- setup.py
- setuptest
- - __init__.py
- - __main__.py

setup.py 文件包含:

from setuptools import setup

setup(
    name         = 'setuptest',
    version      = '0.1',
    packages     = ['setuptest'],

    entry_points = {
        'console_scripts' : ['setuptest = setuptest.__main__:main']
        } ,
    )

__main__.py 文件仅包含:

#!/usr/bin/env python2

def main ():
    print "hai"

if __name__ == '__main__':
    main()

在项目根目录中执行此操作:

 —— — time python2 setuptest
hai

real    0m0.021s
user    0m0.017s
sys     0m0.004s

总脚本执行时间为 21 毫秒,但是,在 运行 sudo python2 setup.py install 之后执行:

 —— — time setuptest 
hai

real    0m0.158s
user    0m0.144s
sys     0m0.012s
 —— — 

给我 158 毫秒。这 +150 秒的启动延迟时间是一致的,并且在 I 使用 setuptools 时全面发生,但不会发生在我通过包管理器安装的东西或手动安装其他人的项目时,这让我不得不认为我显然做错了什么。

好吧,当您使用 setuptools 安装软件时,它会在 bin 目录中生成可执行脚本,如下所示:

import sys
from pkg_resources import load_entry_point

if __name__ == '__main__':
    sys.exit(
        load_entry_point('<PACKAGE_NAME>', 'console_scripts', '<ENTRY_POINT>')()
    )

因为 load_entry_point() 将解析 sys.path 中所有可用的包,您安装的位置和包越多,构建列表和查找所需的时间就越长。

有关更多详细信息,我们需要查看 setuptools 的 load_entry_point() 实现:

来自 setuptools.py: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)

来自 'setuptools.py:get_distribution()':

def get_distribution(dist):
    """Return a current distribution object for a Requirement or string"""
    if isinstance(dist,basestring): 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

来自 setuptools.py:Distribution.load_entry_point():

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()

来自 setuptools.py:Distribution.get_entry_info():

def get_entry_info(self, group, name):
    """Return the EntryPoint object for `group`+`name`, or ``None``"""
    return self.get_entry_map(group).get(name)

我会把它留在那里,你可以跟进到它变得昂贵的地方。我想 Distribution where the mapping is done (like the _dep_map 属性) 中的方法在执行时可能会非常昂贵。

好吧,我自己找到了令我困惑的答案,但它说明了为什么其他人更快。事实证明,尽管 setuptools 被推荐为更新更好,但出于某种原因它也增加了巨大的性能损失,至少在我的系统上 distutils 没有。

所有快速使用的包都使用了distutils

将示例编辑为:

from distutils.core import setup

setup(
    name         = 'disttest',
    version      = '0.1',
    packages     = ['disttest'],

    scripts      = ['bin/disttest']
    )

其中 bin/disttest 是项目根目录中的一个可执行文件,它作为真实程序的简单包装完全解决了这个问题。从 distutils.core 而不是 setuptools 导入似乎是关键。遗憾的是 distuitls 没有方便的入口点机制。

看来,如果您使用 'python setup.py install' 或 'pip install .'(创建 .egg 文件)从源安装项目,则生成的可执行脚本使用 pkg_resources,即慢。

但是,如果您先构建二进制 wheel 文件 (.whl),然后安装 wheel,生成的可执行脚本似乎不会从 pkg_resources 导入,而且速度更快。以任意项目为例,下面是两种不同方式安装cookiecutter项目的结果。

https://github.com/audreyr/cookiecutter

如果此项目是使用 'python setup.py install' 从源代码安装的,生成的可执行脚本包含来自 pkg_resources 的导入(而且很慢):

#!/usr/local/opt/python3/bin/python3.5
# EASY-INSTALL-ENTRY-SCRIPT: 'cookiecutter==1.5.1','console_scripts','cookiecutter'
__requires__ = 'cookiecutter==1.5.1'
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('cookiecutter==1.5.1', 'console_scripts', 'cookiecutter')()
    )

但是,如果使用以下两个命令构建并安装 wheel 文件:

python setup.py bdist_wheel
pip install dist/cookiecutter-1.5.1-py2.py3-none-any.whl

可执行脚本不包含来自 pkg_resources 的导入(并且速度更快):

#!/usr/local/opt/python3/bin/python3.5

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

from cookiecutter.__main__ import main

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