为什么在Python中函数/方法的调用很耗费资源?

30
这篇帖子中,Guido van Rossum说函数调用可能会很耗费资源,但我不明白为什么以及它可能有多么耗费资源。
一个简单的函数调用会给你的代码添加多少延迟,为什么会这样呢?

1
请注意,“昂贵”是相对的。 - Martijn Pieters
1
他在帖子中说,为什么“创建堆栈帧是昂贵的”。 - OMGtechy
4
不一定 - 静态语言通常可以在编译时完成大部分工作,并且有时会完全编译掉函数调用。 - Gareth Latty
1
@thefourtheye:像C这样的语言在运行时几乎不进行任何验证;例如,不会检查参数是否与函数预期的参数匹配(编译器应在编译期间检查此问题)。 - John Zwinck
3
@thejohnbackes,这是一篇原始文章的链接。 https://web.archive.org/web/20180919031245/https://plus.google.com/115212051037621986145/posts/HajXHPGN752 - Phil Kang
显示剩余4条评论
3个回答

33

函数调用需要挂起当前的执行框架,并创建一个新框架并推入堆栈。与许多其他操作相比,这是相对昂贵的。

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

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

这意味着调用一百万次空函数只需要1/6秒的时间;你可以将所需时间与你打算放入函数中的内容进行比较;如果性能是问题,则需要考虑那0.15秒。


3
当你说“相对于许多其他操作而言比较昂贵”时,你想到哪些操作?例如,在内存中创建一个新变量、赋值、语句等等。 - VGonPa
这太笼统了;你应该针对你具体的问题自己进行计时。 - Martijn Pieters
3
我并没有特定的问题,我只是想知道为什么在某些情况下避免函数调用更可取,并了解这些情况是什么。以下是一些时间结果:timeit.timeit('"abc"+"def"') # 0.0361530780792 timeit.timeit('a=[]') # 0.0726218223572 - VGonPa
2
"abc" + "def" 是一个常量查找;编译器已经折叠了连接。 - Martijn Pieters

24

Python的函数调用开销相对较高,这是我们为使用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

注意:

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

参数传递

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查询最后加载的对象的哈希表...

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

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

fn(coordinates(obj))

在实际应用中

你会在如下形式的实际应用中看到这些pythopticisms:

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

这样做有几个优点:

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

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

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

13
任何形如“X很贵”的陈述都没有考虑到性能总是相对于其他正在进行的事情以及相对于任务可以完成的方式而言的。
在SO上有许多关于可能会出现但通常不会出现性能问题的问题。
至于函数调用是否昂贵,有一个一般的两部分回答。
1. 对于那些执行非常少且不调用进一步子函数的函数,在特定应用程序中占据了总挂钟时间的10%以上的函数,值得尝试将它们内联或以其他方式减少调用成本。
2. 在包含复杂数据结构和/或高层次抽象的应用程序中,函数调用之所以昂贵,不是因为它们需要花费很长时间,而是因为它们引诱您进行比严格必要更多的调用。当这种情况发生在多个抽象级别上时,效率的低下会相互增加,产生难以局限的复合减速。
生产高效代码的方法是事后而不是事先。 首先编写干净且易于维护的代码,包括您喜欢的函数调用。 然后在以实际工作负载运行时,让它告诉您可以做什么来加速它。 这里有一个例子。

4
Python的"CALL_FUNCTION"操作比大多数其他语言中等效的"CALL"操作更昂贵一个数量级。其中的原因是每次调用都必须查找函数,即使在循环中也是如此;部分原因是Python的参数传递系统;还有一部分原因是装饰器和Python的monkey patching能力。这就是为什么Python本身建议您更喜欢接收可迭代对象的函数,而不是重复调用函数。 - kfsone
@kfsone:谢谢。我不知道那个。当然,如果你只需要执行一次或少量的时间,那就没关系了。但如果它在你的内部循环中,那将会产生巨大的影响。(当然,我的最爱技术,(谦虚咳嗽)随机暂停,会发现它。) - Mike Dunlavey
3
最近我在做一个使用 "esrapy" 来解析文件的项目。它花了 5 分钟来解析 1200 个小文件。我改变了一个函数和一个调用站点,将 x = [call(i) for i in iterable] 改为 x = call(iterable) -> 2 分钟。加了一些名称提升(例如:spacesRe = re.compile('\s+') -> spaces = re.compile('\s+').search,在循环中使用 obj.info.array.add -> add = obj.info.array.add; for ...: add(...))后,时间缩短到了 50 秒。 - kfsone
2
因为这个答案主要讨论了何时和如何进行优化,而问题是关于为什么在Python中函数调用很昂贵(考虑到在许多语言中,函数调用可以为0成本),所以被Downvote是可以理解的。 - pro-gramer

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接