__add__和+运算符之间的性能差异

3

我正在阅读《Python学习手册第5版》,我需要对这段话进行更多的解释:

例如,字符串的__add__方法实际上执行连接操作;尽管Python在内部将以下第一个映射到第二个,但通常不应该使用第二种形式(它不太直观,而且可能运行得更慢):

>>> S+'NI!'
'spamNI!'
>>> S.__add__('NI!')
'spamNI!'

所以我的问题是,为什么它会运行得更慢?

只需要分解这两个表达式,你就能找到它们的不同之处。 - Abdul Niyas P M
2
请注意,“+”和“__add__”不是等价的。 “+”还会调用“__radd__”,并可能完全跳过“__add__”,除了重新解释“NotImplemented”返回值之外。 - MisterMiyagi
2个回答

7
>>> def test(a, b):
...     return a + b
... 
>>> def test2(a, b):
...     return a.__add__(b)
... 
>>> import dis
>>> dis.dis(test)
  2           0 LOAD_FAST                0 (a)
              3 LOAD_FAST                1 (b)
              6 BINARY_ADD          
              7 RETURN_VALUE        
>>> dis.dis(test2)
  2           0 LOAD_FAST                0 (a)
              3 LOAD_ATTR                0 (__add__)
              6 LOAD_FAST                1 (b)
              9 CALL_FUNCTION            1
             12 RETURN_VALUE        

BINARY_ADD 指令代替 2 条指令:LOAD_ATTRCALL_FUNCTION。由于 BINARY_ADD 做的事情(几乎)相同(但是用 C 实现),所以我们可以期望它会稍微快一点。不过这个差别几乎不会被注意到。
补充一下:这与汇编语言的工作方式类似。通常,当有一条单独的指令可以取代一系列指令时,它会表现得更好。例如,在 x64 中,LEA 指令可以用其他一系列指令来替代。但是它们的表现不如LEA好。
但是这里有一个需要注意的地方(这也解释了为什么我开始谈论x64汇编)。有时候一条指令实际上会表现得更糟。可以看看臭名昭著的LOOP指令。这种违反直觉行为可能有很多原因,例如:稍有不同的假设、未经优化的实现、历史原因、漏洞等等。
结论是,在Python中,+ 理论上应该比__add__ 更快,但是一定要进行测量。

1
“始终衡量”- 我完全同意,但在这样做时,请在您的应用程序上下文中进行测量,并不要仅通过比较 +__add__ 的微基准测试结果来选择。很可能存在许多“更糟糕”的性能问题,这两种加法方式之间的差异只是其中之一。 - poke

4
可能已经解释过了,+ 运算符实际上会在幕后调用 __add__。因此,当你执行 S + 'NI!' 时,幕后实际上是调用了 __add__(如果 S 存在的话)。从语义上讲,两个版本都完全相同。
不同之处在于代码对应的内容。你可能已经知道,Python 被编译成字节码,然后被执行。字节码操作决定了解释器需要执行哪些步骤。你可以使用 dis 模块查看字节码。
>>> import dis
>>> dis.dis("S+'NI!'")
  1           0 LOAD_NAME                0 (S)
              2 LOAD_CONST               0 ('NI!')
              4 BINARY_ADD
              6 RETURN_VALUE
>>> dis.dis("S.__add__('NI!')")
  1           0 LOAD_NAME                0 (S)
              2 LOAD_METHOD              1 (__add__)
              4 LOAD_CONST               0 ('NI!')
              6 CALL_METHOD              1

如您所见,这里的区别基本上是 + 运算符仅执行 BINARY_ADD,而 __add__ 调用则加载实际方法并执行它。

当解释器看到 BINARY_ADD 时,会自动查找 __add__ 的实现并调用它,但是它可以比在 Python 字节码中查找方法更高效地完成此操作。

因此,通过显式调用 __add__,您阻止了解释器使用更快的路线到达实现。

话虽如此,差异微不足道。如果您计时两个调用之间的差异,您可以看到差异,但它确实不是很大(这是 1000 万次调用):

>>> timeit("S+'NI!'", setup='S = "spam"', number=10**7)
0.45791053899995404
>>> timeit("S.__add__('NI!')", setup='S = "spam"', number=10**7)
1.0082074819999889

请注意,这些结果并不总是像这样。在计时自定义类型时(使用非常简单的__add__实现),对__add__的调用可能会更快:
>>> timeit("S+'NI!'", setup='from __main__ import SType;S = SType()', number=10**7)
0.7971681049998551
>>> timeit("S.__add__('NI!')", setup='from __main__ import SType;S = SType()', number=10**7)
0.6606798959999196

这里的差异非常小,但是+确实较慢。
总之,你不应该担心这些差异。选择更易读的方式,几乎所有时候都是+。如果您需要关注性能,则需要分析整个应用程序,并且不要信任这种微基准测试。当查看您的应用程序时,它们并没有帮助,而在99.99%的情况下,这两种方法之间的差异不会有影响。更有可能的是,您的应用程序中存在其他瓶颈,它们将使其变慢。

换句话说,对于内置类型来说,+ 进行了极致优化。 - deceze
你最后的例子(使用自定义__add__)让我非常好奇,所以我开始深入研究它。理论上来说,自定义与否不应该有影响,对吧?我做了很多测试,有时候+更快,有时候__add__更快。似乎__add__的实现并不重要。但是当它是自定义的时候,它会严重占据执行时间。而且由于差异非常小(在我的机器上进行1000万轮后加减100毫秒左右),因此我认为这是由于外部(例如非Python)因素造成的,可以安全地假设如此。 - freakish
@freakish 实际上我之前也考虑过这个问题,因为我还没有一个完美的解释。其中一个原因可能是因为 + 实际上不仅仅调用 __add__ 方法;当我们说两者是等效的时,这只是一种简化(因为 + 也会寻找其他方法来调用)。另一个原因可能是从本地执行 BINARY_ADD 切换回执行 Python 代码会对性能产生一些小影响。 - poke

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