def main():
for i in xrange(10**8):
pass
main()
这段 Python 中的代码运行在(注:计时是用 Linux 中 BASH 中的 time 函数完成的。)
real 0m1.841s
user 0m1.828s
sys 0m0.012s
但是,如果 for 循环没有放在函数中,
for i in xrange(10**8):
pass
然后它运行更长的时间:
real 0m4.543s
user 0m4.524s
sys 0m0.012s
为什么是这样?
time
命令。
在函数内部,字节码是:
2 0 SETUP_LOOP 20 (to 23)
3 LOAD_GLOBAL 0 (xrange)
6 LOAD_CONST 3 (100000000)
9 CALL_FUNCTION 1
12 GET_ITER
>> 13 FOR_ITER 6 (to 22)
16 STORE_FAST 0 (i)
3 19 JUMP_ABSOLUTE 13
>> 22 POP_BLOCK
>> 23 LOAD_CONST 0 (None)
26 RETURN_VALUE
在顶层,字节码是:
1 0 SETUP_LOOP 20 (to 23)
3 LOAD_NAME 0 (xrange)
6 LOAD_CONST 3 (100000000)
9 CALL_FUNCTION 1
12 GET_ITER
>> 13 FOR_ITER 6 (to 22)
16 STORE_NAME 1 (i)
2 19 JUMP_ABSOLUTE 13
>> 22 POP_BLOCK
>> 23 LOAD_CONST 2 (None)
26 RETURN_VALUE
不同之处在于 STORE_FAST
比 STORE_NAME
更快 (!)。这是因为在函数中,i
是局部的,但在顶层它是全局的。
要检查字节码,请使用 dis
module。我能够直接反汇编该函数,但要反汇编顶层代码,我必须使用 compile
builtin。
您可能会问为什么存储局部变量比存储全局变量更快。这是一个 CPython 实现细节。
请记住,CPython 被编译为解释器运行的字节码。编译函数时,局部变量存储在固定大小的数组(not dict
)中,变量名称分配给索引。这是可能的,因为您不能动态地将局部变量添加到函数中。然后检索局部变量实际上是对列表的指针查找和 PyObject
上的引用计数增加,这是微不足道的。
将此与全局查找 (LOAD_GLOBAL
) 进行对比,后者是真正的 dict
搜索,涉及哈希等。顺便说一句,如果您希望它是全局的,这就是为什么您需要指定 global i
:如果您曾经分配给范围内的变量,编译器将发出 STORE_FAST
以供其访问,除非您告诉它不要这样做。
顺便说一句,全局查找仍然非常优化。属性查找foo.bar
是真的慢的!
这是关于局部变量效率的小illustration。
def foo_func: x = 5
,x
是函数的局部变量。访问 x
是本地的。 foo = SomeClass()
、foo.bar
是属性访问。 val = 5
全局是全局的。至于速度本地 >全球>根据我在这里读到的属性。因此访问 foo_func
中的 x
最快,其次是 val
,其次是 foo.bar
。 foo.attr
不是本地查找,因为在这个 convo 的上下文中,我们所说的本地查找是对属于函数的变量的查找。
globals()
函数。如果您想了解更多信息,您可能必须开始查看 Python 的源代码。 CPython 只是 Python 通常实现的名称——所以你可能已经在使用它了!
除了局部/全局变量存储时间之外,操作码预测使函数更快。
正如其他答案所解释的,该函数在循环中使用 STORE_FAST
操作码。这是函数循环的字节码:
>> 13 FOR_ITER 6 (to 22) # get next value from iterator
16 STORE_FAST 0 (x) # set local variable
19 JUMP_ABSOLUTE 13 # back to FOR_ITER
通常,当程序运行时,Python 会一个接一个地执行每个操作码,跟踪堆栈并在每个操作码执行后对堆栈帧进行其他检查。操作码预测意味着在某些情况下 Python 能够直接跳转到下一个操作码,从而避免了一些这种开销。
在这种情况下,每次 Python 看到 FOR_ITER
(循环的顶部)时,它都会“预测”STORE_FAST
是它必须执行的下一个操作码。然后 Python 会查看下一个操作码,如果预测正确,它会直接跳转到 STORE_FAST
。这具有将两个操作码压缩为单个操作码的效果。
另一方面,STORE_NAME
操作码用于全局级别的循环中。当 Python 看到此操作码时,*not* 会做出类似的预测。相反,它必须回到评估循环的顶部,这对循环执行的速度有明显的影响。
为了提供有关此优化的更多技术细节,这里引用 ceval.c
文件(Python 虚拟机的“引擎”):
一些操作码往往成对出现,因此可以在运行第一个代码时预测第二个代码。例如,GET_ITER 通常后跟 FOR_ITER。而 FOR_ITER 之后通常是 STORE_FAST 或 UNPACK_SEQUENCE。验证预测需要对寄存器变量与常数进行单次高速测试。如果配对良好,那么处理器自己的内部分支预测成功的可能性很高,从而导致到下一个操作码的过渡开销几乎为零。一个成功的预测可以避免 eval 循环,包括它的两个不可预测的分支,HAS_ARG 测试和 switch-case。结合处理器的内部分支预测,成功的 PREDICT 具有使两个操作码运行起来的效果,就好像它们是结合了主体的单个新操作码一样。
我们可以在 FOR_ITER
操作码的源代码中看到对 STORE_FAST
进行预测的确切位置:
case FOR_ITER: // the FOR_ITER opcode case
v = TOP();
x = (*v->ob_type->tp_iternext)(v); // x is the next value from iterator
if (x != NULL) {
PUSH(x); // put x on top of the stack
PREDICT(STORE_FAST); // predict STORE_FAST will follow - success!
PREDICT(UNPACK_SEQUENCE); // this and everything below is skipped
continue;
}
// error-checking and more code for when the iterator ends normally
PREDICT
函数扩展为 if (*next_instr == op) goto PRED_##op
,即我们只是跳转到预测操作码的开头。在这种情况下,我们跳到这里:
PREDICTED_WITH_ARG(STORE_FAST);
case STORE_FAST:
v = POP(); // pop x back off the stack
SETLOCAL(oparg, v); // set it as the new local variable
goto fast_next_opcode;
局部变量现在已设置,下一个操作码已准备好执行。 Python 继续遍历 iterable 直到它到达末尾,每次都进行成功的预测。
Python wiki page 包含有关 CPython 虚拟机如何工作的更多信息。
HAS_ARG
测试永远不会发生(除非在编译和运行时都启用了低级跟踪,而正常的构建不会这样做),只留下一个不可预知的跳转。
PREDICT
宏被完全禁用;相反,大多数情况以直接分支的 DISPATCH
结尾。但在分支预测 CPU 上,效果类似于 PREDICT
,因为分支(和预测)是按操作码进行的,因此增加了分支预测成功的几率。
不定期副业成功案例分享
global i
插入main
函数使运行时间相等。locals()
或inspect.getframe()
ETC。)。通过常量整数查找数组元素比搜索字典要快得多。