【问题标题】:Why is a function/method call in python expensive?为什么 python 中的函数/方法调用很昂贵?
【发布时间】:2014-05-18 13:23:37
【问题描述】:

this post 中,Guido van Rossum 说函数调用可能很昂贵,但我不明白为什么,也不知道会花多少钱。

一个简单的函数调用会给您的代码增加多少延迟?为什么?

【问题讨论】:

  • 请注意,“昂贵”可以是相对的。
  • 他在帖子中说原因; creating a stack frame is expensive.
  • @thefourtheye 不一定——静态语言通常可以在编译时完成大部分工作,有时会一起编译出函数调用。
  • @thefourtheye:像 C 这样的语言在运行时很少做验证;例如,不检查参数是否与函数的预期参数匹配(编译器应在编译期间检查这一点)。
  • @thejohnbackes 这是原始文章的链接。 web.archive.org/web/20180919031245/https://plus.google.com/…

标签: python profiling function-calls python-internals


【解决方案1】:

函数调用需要暂停当前执行帧,并创建一个新帧并将其压入堆栈。与许多其他操作相比,这相对昂贵。

您可以使用timeit 模块测量所需的确切时间:

>>> import timeit
>>> def f(): pass
... 
>>> timeit.timeit(f)
0.15175890922546387

100 万次调用空函数需要 1/6 秒;您可以将所需的时间与您考虑放入函数的时间进行比较;如果性能是一个问题,则需要考虑 0.15 秒。

【讨论】:

  • 当您说与许多其他操作相比相对昂贵时,您指的是哪种操作?例如。在内存中创建一个新变量,赋值,语句,...
  • 那太宽泛了;而是针对您的具体问题自己安排时间。
  • 这不是我有一个具体的问题,我只是想了解为什么以及在哪些情况下最好避免函数调用。一些时间:timeit.timeit('"abc"+"def"') # 0.0361530780792timeit.timeit('a=[]') # 0.0726218223572
  • "abc" + "def" 是一个常量查找;编译器已经折叠了串联。
【解决方案2】:

“X 是昂贵的”形式的任何陈述都没有考虑到性能始终与正在发生的其他事情相关,并且与可以完成任务的其他事情相关。

关于 SO 的许多问题表达了对可能存在但通常不是性能问题的担忧。

关于函数调用是否昂贵,有一个通用的两部分答案。

  1. 对于功能很少且不调用进一步的子功能,并且在特定应用程序中占总挂钟时间的 10% 以上的功能,值得尝试将它们内联或以其他方式降低调用成本。

  2. 在包含复杂数据结构和/或高抽象层次结构的应用程序中,函数调用代价高昂不是因为它们花费的时间,而是因为它们会诱使您进行比严格必要的更多的调用。当这种情况发生在多个抽象级别上时,效率低下的情况会叠加在一起,从而产生难以定位的复合减速。

产生高效代码的方法是后验,而不是先验。 首先编写代码,使其干净且可维护,包括您喜欢的函数调用。 然后,当它以实际工作负载运行时,让它告诉您可以做些什么来加快它的速度。 Here's an example.

【讨论】:

  • 众所周知,Python 的“CALL_FUNCTION”操作比大多数其他语言中等效的“CALL”操作要贵一个数量级。这是因为您必须在每次调用时查找函数,即使在循环中也是如此;部分是因为 Python 的参数传递系统;部分是因为装饰器,部分是因为 Python 的猴子修补功能。这就是为什么 Python 本身建议您使用可迭代的函数而不是重复调用函数的原因。
  • @kfsone:谢谢。我不知道。当然,如果你只做一次或很少的时间,也没关系。但是,如果它在您的内部循环中,它将产生巨大的影响。 (当然,我最喜欢的技巧,(适度咳嗽)随机停顿,会发现它。)
  • 我最近在做一个使用“esrapy”来解析文件的项目。解析1200个小文件用了5分钟。我将一个功能和一个呼叫站点更改为x = call(iterable) 而不是x = [call(i) for i in iterable] -> 2 分钟。一些名字提升(spacesRe = re.compile('\s+') -> spaces = re.compile('\s+').searchobj.info.array.add 在一个循环中 -> add = obj.info.array.add; for ...: add(...)),它下降到 50 秒。
  • 投反对票,因为这个答案主要是关于何时以及如何优化,而问题是关于为什么函数调用在 python 中很昂贵(考虑到在许多语言中函数调用可能是 0 成本,问题是可以理解)。
【解决方案3】:

Python 有一个"relatively high" 函数调用开销,这是我们为 Python 的一些最有用的功能支付的成本。

猴子补丁:

您在 Python 中拥有如此强大的猴子补丁/覆盖行为,解释器无法保证给定

 a, b = X(1), X(2)
 return a.fn() + b.fn() + a.fn()

a.fn() 和 b.fn() 相同,或者调用 b.fn() 后 a.fn() 将相同。

In [1]: def f(a, b):
   ...:     return a.fn() + b.fn() + c.fn()
   ...:

In [2]: dis.dis(f)
  1           0 LOAD_FAST                0 (a)
              3 LOAD_ATTR                0 (fn)
              6 CALL_FUNCTION            0
              9 LOAD_FAST                1 (b)
             12 LOAD_ATTR                0 (fn)
             15 CALL_FUNCTION            0
             18 BINARY_ADD
             19 LOAD_GLOBAL              1 (c)
             22 LOAD_ATTR                0 (fn)
             25 CALL_FUNCTION            0
             28 BINARY_ADD
             29 RETURN_VALUE

在上面,您可以看到在每个位置都查找了“fn”。这同样适用于变量,但人们似乎更清楚这一点。

In [11]: def g(a):
    ...:     return a.i + a.i + a.i
    ...:

In [12]: dis.dis(g)
  2           0 LOAD_FAST                0 (a)
              3 LOAD_ATTR                0 (i)
              6 LOAD_FAST                0 (a)
              9 LOAD_ATTR                0 (i)
             12 BINARY_ADD
             13 LOAD_FAST                0 (a)
             16 LOAD_ATTR                0 (i)
             19 BINARY_ADD
             20 RETURN_VALUE

更糟糕的是,因为模块可以猴子修补/替换自己/彼此,如果你正在调用全局/模块函数,则每次都必须查找全局/模块:

In [16]: def h():
    ...:     v = numpy.vector(numpy.vector.identity)
    ...:     for i in range(100):
    ...:         v = numpy.vector.add(v, numpy.vector.identity)
    ...:

In [17]: dis.dis(h)
  2           0 LOAD_GLOBAL              0 (numpy)
              3 LOAD_ATTR                1 (vector)
              6 LOAD_GLOBAL              0 (numpy)
              9 LOAD_ATTR                1 (vector)
             12 LOAD_ATTR                2 (identity)
             15 CALL_FUNCTION            1
             18 STORE_FAST               0 (v)

  3          21 SETUP_LOOP              47 (to 71)
             24 LOAD_GLOBAL              3 (range)
             27 LOAD_CONST               1 (100)
             30 CALL_FUNCTION            1
             33 GET_ITER
        >>   34 FOR_ITER                33 (to 70)
             37 STORE_FAST               1 (i)

  4          40 LOAD_GLOBAL              0 (numpy)
             43 LOAD_ATTR                1 (vector)
             46 LOAD_ATTR                4 (add)
             49 LOAD_FAST                0 (v)
             52 LOAD_GLOBAL              0 (numpy)
             55 LOAD_ATTR                1 (vector)
             58 LOAD_ATTR                2 (identity)
             61 CALL_FUNCTION            2
             64 STORE_FAST               0 (v)
             67 JUMP_ABSOLUTE           34
        >>   70 POP_BLOCK
        >>   71 LOAD_CONST               0 (None)
             74 RETURN_VALUE

解决方法

考虑捕获或导入您不希望发生变异的任何值:

def f1(files):
    for filename in files:
        if os.path.exists(filename):
            yield filename

# vs

def f2(files):
    from os.path import exists
    for filename in files:
        if exists(filename):
            yield filename

# or

def f3(files, exists=os.path.exists):
    for filename in files:
        if exists(filename):
            yield filename

另见“野外”部分

但并不总是可以导入;比如你可以导入 sys.stdin 但不能导入 sys.stdin.readline,numpy 类型也会有类似的问题:

In [15]: def h():
    ...:     from numpy import vector
    ...:     add = vector.add
    ...:     idy = vector.identity
    ...:     v   = vector(idy)
    ...:     for i in range(100):
    ...:         v = add(v, idy)
    ...:

In [16]: dis.dis(h)
  2           0 LOAD_CONST               1 (-1)
              3 LOAD_CONST               2 (('vector',))
              6 IMPORT_NAME              0 (numpy)
              9 IMPORT_FROM              1 (vector)
             12 STORE_FAST               0 (vector)
             15 POP_TOP

  3          16 LOAD_FAST                0 (vector)
             19 LOAD_ATTR                2 (add)
             22 STORE_FAST               1 (add)

  4          25 LOAD_FAST                0 (vector)
             28 LOAD_ATTR                3 (identity)
             31 STORE_FAST               2 (idy)

  5          34 LOAD_FAST                0 (vector)
             37 LOAD_FAST                2 (idy)
             40 CALL_FUNCTION            1
             43 STORE_FAST               3 (v)

  6          46 SETUP_LOOP              35 (to 84)
             49 LOAD_GLOBAL              4 (range)
             52 LOAD_CONST               3 (100)
             55 CALL_FUNCTION            1
             58 GET_ITER
        >>   59 FOR_ITER                21 (to 83)
             62 STORE_FAST               4 (i)

  7          65 LOAD_FAST                1 (add)
             68 LOAD_FAST                3 (v)
             71 LOAD_FAST                2 (idy)
             74 CALL_FUNCTION            2
             77 STORE_FAST               3 (v)
             80 JUMP_ABSOLUTE           59
        >>   83 POP_BLOCK
        >>   84 LOAD_CONST               0 (None)
             87 RETURN_VALUE

CAVEAT EMPTOR: - 捕获变量不是零成本操作,它会增加帧大小, - 仅在识别热代码路径后使用,


参数传递

Python 的参数传递机制看起来微不足道,但与大多数语言不同,它的成本很多。我们正在谈论将参数分为 args 和 kwargs:

f(1, 2, 3)
f(1, 2, c=3)
f(c=3)
f(1, 2)  # c is auto-injected

在 CALL_FUNCTION 操作中有很多工作要做,包括从 C 层到 Python 层并返回的潜在转换。

除此之外,传递的参数往往还需要查:

f(obj.x, obj.y, obj.z)

考虑:

In [28]: def fn(obj):
    ...:     f = some.module.function
    ...:     for x in range(1000):
    ...:         for y in range(1000):
    ...:             f(x + obj.x, y + obj.y, obj.z)
    ...:

In [29]: dis.dis(fn)
  2           0 LOAD_GLOBAL              0 (some)
              3 LOAD_ATTR                1 (module)
              6 LOAD_ATTR                2 (function)
              9 STORE_FAST               1 (f)

  3          12 SETUP_LOOP              76 (to 91)
             15 LOAD_GLOBAL              3 (range)
             18 LOAD_CONST               1 (1000)
             21 CALL_FUNCTION            1
             24 GET_ITER
        >>   25 FOR_ITER                62 (to 90)
             28 STORE_FAST               2 (x)

  4          31 SETUP_LOOP              53 (to 87)
             34 LOAD_GLOBAL              3 (range)
             37 LOAD_CONST               1 (1000)
             40 CALL_FUNCTION            1
             43 GET_ITER
        >>   44 FOR_ITER                39 (to 86)
             47 STORE_FAST               3 (y)

  5          50 LOAD_FAST                1 (f)
             53 LOAD_FAST                2 (x)
             56 LOAD_FAST                0 (obj)
             59 LOAD_ATTR                4 (x)
             62 BINARY_ADD
             63 LOAD_FAST                3 (y)
             66 LOAD_FAST                0 (obj)
             69 LOAD_ATTR                5 (y)
             72 BINARY_ADD
             73 LOAD_FAST                0 (obj)
             76 LOAD_ATTR                6 (z)
             79 CALL_FUNCTION            3
             82 POP_TOP
             83 JUMP_ABSOLUTE           44
        >>   86 POP_BLOCK
        >>   87 JUMP_ABSOLUTE           25
        >>   90 POP_BLOCK
        >>   91 LOAD_CONST               0 (None)
             94 RETURN_VALUE

“LOAD_GLOBAL”要求对名称进行散列处理,然后在全局表中查询该散列值。这是一个 O(log N) 操作。

但请考虑一下:对于我们的两个简单的 0-1000 循环,我们正在这样做一百万次......

LOAD_FAST 和 LOAD_ATTR 也是哈希表查找,它们只限于特定的哈希表。 LOAD_FAST 查询 locals() 哈希表,LOAD_ATTR 查询上次加载的对象的哈希表...

但还要注意,我们在那里调用了一个函数一百万次。幸运的是,它是一个内置函数,内置函数的开销要小得多;但如果这真的是您的性能热点,您可能需要考虑通过执行以下操作来优化范围的开销:

x, y = 0, 0
for i in range(1000 * 1000):
    ....
    y += 1
    if y > 1000:
        x, y = x + 1, 0

可以对捕获变量进行一些修改,但它可能对这段代码的性能影响最小,并且只会降低它的可维护性。

但是这个问题的核心pythonic修复是使用生成器或迭代器:

for i in obj.values():
    prepare(i)

# vs

prepare(obj.values())

for i in ("left", "right", "up", "down"):
    test_move(i)

# vs

test_move(("left", "right", "up", "down"))

for x in range(-1000, 1000):
    for y in range(-1000, 1000):
        fn(x + obj.x, y + obj.y, obj.z)

# vs

def coordinates(obj):
    for x in range(obj.x - 1000, obj.x + 1000 + 1):
        for y in range(obj.y - 1000, obj.y + 1000 + 1):
          yield obj.x, obj.y, obj.z

fn(coordinates(obj))

在野外

您会以如下形式在野外看到这些 pythopticisms:

def some_fn(a, b, c, stdin=sys.stdin):
    ...

这有几个优点:

  • 影响此函数的 help(),(默认输入为标准输入)
  • 为单元测试提供挂钩,
  • 将 sys.stdin 提升到本地(LOAD_FAST 与 LOAD_GLOBAL+LOAD_ATTR)

大多数 numpy 调用要么采用或具有采用列表、数组等的变体,如果您不使用这些,您可能会错过 99% 的 numpy 好处。

def distances(target, candidates):
    values = []
    for candidate in candidates:
        values.append(numpy.linalg.norm(candidate - target))
    return numpy.array(values)

# vs

def distances(target, candidates):
    return numpy.linalg.norm(candidates - target)

(注意:这不一定是获取距离的最佳方法,尤其是如果您不打算将距离值转发到其他地方;例如,如果您正在进行范围检查,使用更具选择性的方法可能更有效避免使用 sqrt 操作的方法)

对可迭代对象进行优化不仅意味着传递它们,还意味着返回它们

def f4(files, exists=os.path.exists):
    return (filename for filename in files if exists(filename))
           ^- returns a generator expression

【讨论】:

    猜你喜欢
    • 2011-06-18
    • 1970-01-01
    • 1970-01-01
    • 2010-12-12
    • 2017-06-04
    • 2017-10-12
    • 2019-04-08
    • 1970-01-01
    • 2011-08-03
    相关资源
    最近更新 更多