为什么Python中的函数/方法调用代价高?

32 投票
3 回答
21303 浏览
提问于 2025-04-18 01:34

这篇文章中,Guido van Rossum提到调用一个函数可能会很耗费资源,但我不太明白为什么,以及到底有多耗费。

一个简单的函数调用会给你的代码增加多少延迟,为什么会这样呢?

3 个回答

13

任何说“X很耗费资源”的说法,都没有考虑到性能总是相对的,跟其他事情的情况以及任务的其他处理方式有关。

在StackOverflow上,有很多问题担心一些可能存在,但通常并不是性能问题的事情。

关于函数调用是否耗费资源,通常有两个方面的回答。

  1. 对于那些做得很少、并且不再调用其他子函数的函数,如果在特定应用中占用了超过10%的总运行时间,那么尝试将它们内联或者以其他方式减少调用成本是值得的。

  2. 在包含复杂数据结构和/或较高抽象层次的应用中,函数调用之所以耗费资源,并不是因为它们所花的时间,而是因为它们会诱使你调用更多的函数,超过了实际需要。当这种情况发生在多个抽象层次时,效率低下会相互叠加,导致整体变慢,这种问题不容易定位。

编写高效代码的方法是事后分析,而不是事前假设。首先编写干净且易于维护的代码,随意使用函数调用。然后在它运行时,使用真实的工作负载,让它告诉你可以做些什么来加快速度。这里有一个例子。

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

Python的函数调用开销相对较高,这是我们为了使用Python一些非常有用的功能所付出的代价。

猴子补丁:

在Python中,你可以随意修改或覆盖某些行为,这让解释器无法保证是相同的,或者在调用会保持不变。

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

在上面的例子中,你可以看到每个地方都要查找'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

更糟糕的是,由于模块可以互相修改或替换,如果你在调用一个全局或模块函数,每次都需要查找这个全局或模块:

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

另请参见“在实际应用中”部分

不过,有时候导入并不总是可行;例如,你可以导入sys.stdin,但不能导入sys.stdin.readline,而numpy类型也可能有类似的问题:

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

注意:

  • 捕获变量并不是零成本操作,它会增加帧的大小,
  • 只有在识别出热点代码路径后才使用,

参数传递

Python的参数传递机制看起来很简单,但与大多数语言不同,它的成本却非常高。我们说的是将参数分为args和kwargs:

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

在CALL_FUNCTION操作中会进行很多工作,包括可能从C层到Python层的转换和返回。

此外,参数通常需要查找才能传递:

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

考虑一下:

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

在这里,“LOAD_GLOBAL”需要对名称进行哈希处理,然后查询全局表以获取该哈希值。这是一个O(log N)的操作。

但想想看:对于我们两个简单的0-1000循环,我们要做一百万次这个操作...

LOAD_FAST和LOAD_ATTR也是哈希表查找,只不过它们限制在特定的哈希表中。LOAD_FAST查询locals()哈希表,LOAD_ATTR查询最后加载对象的哈希表...

但请注意,我们在这里调用一个函数一百万次。幸运的是,这是一个内置函数,内置函数的开销要小得多;但如果这真的是你的性能瓶颈,你可能需要考虑通过以下方式优化range的开销:

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

你可以对捕获变量进行一些黑客操作,但这对代码的性能影响可能微乎其微,反而会让代码变得不易维护。

不过,解决这个问题的核心Pythonic方法是使用生成器或可迭代对象:

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

还有

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

在实际应用中

你会在实际应用中看到这些Pythonic风格的写法,像这样:

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 x, y, z

fn(coordinates(obj))

这有几个优点:

  • 影响这个函数的help()(默认输入是stdin)
  • 为单元测试提供了一个钩子,
  • 将sys.stdin提升为局部变量(LOAD_FAST vs LOAD_GLOBAL+LOAD_ATTR)

大多数numpy调用要么接受,要么有一个变体可以接受列表、数组等,如果你不使用这些,你可能会错过99%的numpy好处。

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

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

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

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

调用一个函数时,当前的执行状态会被暂停,然后会创建一个新的执行状态并把它放到一个叫做“栈”的地方。这种操作相对来说比较耗时,跟很多其他操作比起来要贵一些。

你可以用 timeit 模块来测量具体需要多少时间:

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

对于一百万次调用一个空函数,耗时大约是六分之一秒;你可以把这个时间和你想放进函数里的其他操作时间进行比较;如果性能很重要的话,0.15秒的时间是需要考虑的。

撰写回答