for循环中的项解包

7 投票
1 回答
759 浏览
提问于 2025-04-18 02:33

有一次,我看了Mike Muller关于性能优化的教程(我觉得是这个),脑海中冒出了一个想法:如果性能很重要,就尽量减少在循环中通过索引访问元素的次数。比如说,如果你在循环for x in l中需要多次访问x[1],那么可以先把x[1]的值赋给一个变量,然后在循环中重复使用这个变量。

现在我有一个简单的例子:

import timeit

SEQUENCE = zip(range(1000), range(1, 1001))

def no_unpacking():
    return [item[0] + item[1] for item in SEQUENCE]


def unpacking():    
    return [a + b for a, b in SEQUENCE]


print timeit.Timer('no_unpacking()', 'from __main__ import no_unpacking').timeit(10000)
print timeit.Timer('unpacking()', 'from __main__ import unpacking').timeit(10000)

unpacking()no_unpacking()这两个函数返回的结果是一样的,但实现方式不同:unpacking()在循环中将元素解包到变量ab中,而no_unpacking()则是通过索引来获取值。

在python27中,它显示:

1.25280499458
0.946601867676

换句话说,unpacking()的性能比no_unpacking()快了大约25%。

问题是:

  • 为什么通过索引访问会显著降低速度(即使在这个简单的例子中)?

附加问题:

  • 我也在pypy上试过这两个函数,性能几乎没有差别。为什么会这样?

谢谢大家的帮助。

1 个回答

22

为了回答你的问题,我们可以使用 dis 模块来查看这两个函数生成的字节码:

In [5]: def no_unpacking():
   ...:     s = []
   ...:     for item in SEQUENCE:
   ...:         s.append(item[0] + item[1])
   ...:     return s
   ...: 
   ...: 
   ...: def unpacking():  
   ...:     s = []
   ...:     for a,b in SEQUENCE:
   ...:         s.append(a+b)  
   ...:     return s

我把列表推导式展开了,因为在 Python 3 中查看有趣的字节码会更麻烦。代码是等价的,所以对我们来说并不重要。

第一个函数的字节码是:

In [6]: dis.dis(no_unpacking)
  2           0 BUILD_LIST               0 
              3 STORE_FAST               0 (s) 

  3           6 SETUP_LOOP              39 (to 48) 
              9 LOAD_GLOBAL              0 (SEQUENCE) 
             12 GET_ITER             
        >>   13 FOR_ITER                31 (to 47) 
             16 STORE_FAST               1 (item) 

  4          19 LOAD_FAST                0 (s) 
             22 LOAD_ATTR                1 (append) 
             25 LOAD_FAST                1 (item) 
             28 LOAD_CONST               1 (0) 
             31 BINARY_SUBSCR        
             32 LOAD_FAST                1 (item) 
             35 LOAD_CONST               2 (1) 
             38 BINARY_SUBSCR        
             39 BINARY_ADD           
             40 CALL_FUNCTION            1 (1 positional, 0 keyword pair) 
             43 POP_TOP              
             44 JUMP_ABSOLUTE           13 
        >>   47 POP_BLOCK            

  5     >>   48 LOAD_FAST                0 (s) 
             51 RETURN_VALUE      

注意,循环需要调用 BINARY_SUBSCR 两次来访问元组中的两个元素。

第二个函数的字节码是:

In [7]: dis.dis(unpacking)
  9           0 BUILD_LIST               0 
              3 STORE_FAST               0 (s) 

 10           6 SETUP_LOOP              37 (to 46) 
              9 LOAD_GLOBAL              0 (SEQUENCE) 
             12 GET_ITER             
        >>   13 FOR_ITER                29 (to 45) 
             16 UNPACK_SEQUENCE          2 
             19 STORE_FAST               1 (a) 
             22 STORE_FAST               2 (b) 

 11          25 LOAD_FAST                0 (s) 
             28 LOAD_ATTR                1 (append) 
             31 LOAD_FAST                1 (a) 
             34 LOAD_FAST                2 (b) 
             37 BINARY_ADD           
             38 CALL_FUNCTION            1 (1 positional, 0 keyword pair) 
             41 POP_TOP              
             42 JUMP_ABSOLUTE           13 
        >>   45 POP_BLOCK            

 12     >>   46 LOAD_FAST                0 (s) 
             49 RETURN_VALUE 

注意这里没有需要执行的 BINARY_SUBSCR

所以,看起来 UNPACK_SEQUENCE 加上一个 STORE_FAST(这是解包时增加的额外操作)比执行两个 BINARY_SUBSCR。这是合理的,因为 BINARY_SUBSCR 是一个完整的方法调用,而 UNPACK_SEQUENCESTORE_FAST 是更简单的操作。

你甚至可以在更简单的情况下看到这种差异:

In [1]: def iter_with_index(s):
   ...:     for i in range(len(s)):
   ...:         s[i]
   ...:         

In [2]: def iter_without_index(s):
   ...:     for el in s:el
   ...:     

In [3]: %%timeit s = 'a' * 10000
   ...: iter_with_index(s)
   ...: 
1000 loops, best of 3: 583 us per loop

In [4]: %%timeit s = 'a' * 10000
   ...: iter_without_index(s)
   ...: 
1000 loops, best of 3: 206 us per loop

如你所见,使用显式索引遍历字符串的速度大约慢了 3 倍。这些都是由于调用 BINARY_SUBSCR 带来的额外开销。

关于你的第二个问题:pypy 有 JIT(即时编译器),它能够分析代码并生成一个优化版本,避免索引操作的开销。当它意识到订阅操作是在元组上进行时,它可能能够生成不调用元组方法而直接访问元素的代码,从而完全消除 BINARY_SUBSCR 操作。

撰写回答