Por que o código Python roda mais rápido em uma função?

835
def main():
    for i in xrange(10**8):
        pass
main()

Este trecho de código no Python é executado (Observação: o tempo é feito com a função de tempo no BASH no Linux.)

real    0m1.841s
user    0m1.828s
sys     0m0.012s

No entanto, se o loop for não for colocado em uma função,

for i in xrange(10**8):
    pass

então ele roda por um tempo muito maior:

real    0m4.543s
user    0m4.524s
sys     0m0.012s

Por que é isso?

thedoctar
fonte
16
Como você realmente fez o tempo?
Andrew Jaffe
53
Apenas uma intuição, não tenho certeza se é verdade: eu acho que é por causa de escopos. No caso da função, um novo escopo é criado (ou seja, tipo de hash com nomes de variáveis ​​vinculados ao seu valor). Sem uma função, as variáveis ​​estão no escopo global, quando você pode encontrar muitas coisas, diminuindo a velocidade do loop.
Scharron
4
@Charron Isso não parece ser o caso. Definiu 200k variáveis ​​simuladas no escopo sem afetar visivelmente o tempo de execução.
Deestan
2
Alex Martelli escreveu uma boa resposta sobre esse stackoverflow.com/a/1813167/174728 #
John La Rooy
53
@Scharron você está meio correto. É sobre escopos, mas a razão pela qual é mais rápido nos locais é que os escopos locais são realmente implementados como matrizes em vez de dicionários (já que seu tamanho é conhecido no tempo de compilação).
Katriel

Respostas:

532

Você pode perguntar por que é mais rápido armazenar variáveis ​​locais do que globais. Este é um detalhe de implementação do CPython.

Lembre-se de que o CPython é compilado no bytecode, que o interpretador executa. Quando uma função é compilada, as variáveis ​​locais são armazenadas em uma matriz de tamanho fixo ( não a dict) e os nomes das variáveis ​​são atribuídos aos índices. Isso é possível porque você não pode adicionar dinamicamente variáveis ​​locais a uma função. A recuperação de uma variável local é literalmente uma pesquisa de ponteiro na lista e um aumento de refcount no PyObjectque é trivial.

Compare isso com uma pesquisa global ( LOAD_GLOBAL), que é uma verdadeira dictpesquisa envolvendo um hash e assim por diante. Aliás, é por isso que você precisa especificar global ise deseja que seja global: se você atribuir a uma variável dentro de um escopo, o compilador emitirá STORE_FASTs para seu acesso, a menos que você o solicite.

A propósito, as pesquisas globais ainda são bastante otimizadas. As pesquisas de atributos foo.barsão realmente lentas!

Aqui está uma pequena ilustração sobre a eficiência variável local.

Katriel
fonte
6
Isso também se aplica ao PyPy, até a versão atual (1,8 no momento da redação deste documento). O código de teste do OP é executado quatro vezes mais lento no escopo global em comparação com o interior de uma função.
GDorn
4
@Walkerneo Eles não são, a menos que você tenha dito isso ao contrário. De acordo com o que o katrielalex e o ecatmur estão dizendo, as pesquisas de variáveis ​​globais são mais lentas que as pesquisas de variáveis ​​locais devido ao método de armazenamento.
21712 Jeremy Pridemore
2
@Walkerneo A principal conversa em andamento aqui é a comparação entre pesquisas de variáveis ​​locais dentro de uma função e pesquisas de variáveis ​​globais definidas no nível do módulo. Se você notar no seu comentário original a resposta a esta resposta, disse: "Eu não pensaria que as pesquisas de variáveis ​​globais fossem mais rápidas que as pesquisas de propriedades de variáveis ​​locais". e eles não são. O katrielalex disse que, embora as pesquisas de variáveis ​​locais sejam mais rápidas que as globais, mesmo as globais são bastante otimizadas e mais rápidas que as pesquisas de atributos (que são diferentes). Não tenho espaço suficiente neste comentário para mais.
Jeremy Pridemore
3
@Walkerneo foo.bar não é um acesso local. É um atributo de um objeto. (Perdoe a falta de formatação) def foo_func: x = 5, xé local para uma função. O acesso xé local. foo = SomeClass(), foo.baré o acesso ao atributo. val = 5global é global. Quanto à velocidade local> global> atributo de acordo com o que eu li aqui. Então, acessando xem foo_funcé mais rápido, seguido de val, seguido por foo.bar. foo.attrnão é uma pesquisa local porque, no contexto desta convo, estamos falando de pesquisas locais como uma pesquisa de uma variável que pertence a uma função.
precisa
3
@thedoctar dê uma olhada na globals()função. Se você quiser mais informações do que isso, talvez precise começar a procurar o código-fonte do Python. E CPython é apenas o nome para a implementação usual do Python - então você provavelmente já o está usando!
Katriel
661

Dentro de uma função, o bytecode é:

  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        

No nível superior, o bytecode é:

  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        

A diferença é que STORE_FASTé mais rápido (!) Que STORE_NAME. Isso ocorre porque em uma função ié local, mas no nível superior é global.

Para examinar bytecode, use o dismódulo . Consegui desmontar a função diretamente, mas para desmontar o código de nível superior, tive que usar o compilebuiltin .

ecatmur
fonte
171
Confirmado pela experiência. A inserção global ina mainfunção torna os tempos de execução equivalentes.
Deestan
44
Isso responde à pergunta sem responder à pergunta :) No caso de variáveis ​​de função local, o CPython na verdade as armazena em uma tupla (que é mutável a partir do código C) até que um dicionário seja solicitado (por exemplo locals(), via , inspect.getframe()etc.). Procurar um elemento da matriz por um número inteiro constante é muito mais rápido do que pesquisar um ditado.
DMW
3
É o mesmo com C / C ++, também, o uso de variáveis globais provoca desaceleração significativa
codejammer
3
Esta é a primeira vez que eu vi um bytecode. Como alguém olha para ele e é importante saber?
Zack
4
@gkimsey Eu concordo. Só queria compartilhar duas coisas i) Este comportamento é observado em outras linguagens de programação ii) O agente causal é mais o lado de arquitectura e não a própria linguagem no verdadeiro sentido
codejammer
41

Além dos tempos de armazenamento variáveis ​​locais / globais, a previsão do código de operação torna a função mais rápida.

Como as outras respostas explicam, a função usa o STORE_FASTcódigo de operação no loop. Aqui está o bytecode para o loop da função:

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

Normalmente, quando um programa é executado, o Python executa cada código de operação um após o outro, acompanhando a pilha e pré-formando outras verificações no quadro da pilha após a execução de cada código de operação. A previsão de código de operação significa que, em certos casos, o Python é capaz de pular diretamente para o próximo código de operação, evitando parte dessa sobrecarga.

Nesse caso, toda vez que o Python vir FOR_ITER(a parte superior do loop), ele "prediz" que STORE_FASTé o próximo opcode que ele deve executar. O Python então espreita o próximo código de operação e, se a previsão estiver correta, salta diretamente para STORE_FAST. Isso tem o efeito de espremer os dois códigos de operação em um único código de operação.

Por outro lado, o STORE_NAMEcódigo de operação é usado no loop em nível global. O Python * não * faz previsões semelhantes quando vê esse opcode. Em vez disso, ele deve voltar ao topo do loop de avaliação, o que tem implicações óbvias para a velocidade com que o loop é executado.

Para fornecer mais detalhes técnicos sobre essa otimização, aqui está uma citação do ceval.carquivo (o "mecanismo" da máquina virtual do Python):

Alguns opcodes tendem a vir em pares, tornando possível prever o segundo código quando o primeiro é executado. Por exemplo, GET_ITERgeralmente é seguido por FOR_ITER. E FOR_ITERé frequentemente seguido porSTORE_FAST ou UNPACK_SEQUENCE.

Verificar a previsão custa um único teste de alta velocidade de uma variável de registro em relação a uma constante. Se o emparelhamento foi bom, a predicação de ramificação interna do próprio processador tem uma alta probabilidade de sucesso, resultando em uma transição quase zero para o próximo código de operação. Uma previsão bem-sucedida salva uma viagem pelo loop de avaliação, incluindo seus dois ramos imprevisíveis, o HAS_ARGteste e o caso do switch. Combinado com a previsão de ramificação interna do processador, um êxito PREDICTtem o efeito de executar os dois opcodes como se fossem um único novo opcode com os corpos combinados.

Podemos ver no código fonte do FOR_ITERopcode exatamente onde a previsão STORE_FASTé feita:

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                                     

A PREDICTfunção se expande para, if (*next_instr == op) goto PRED_##opou seja, apenas saltamos para o início do código de operação previsto. Nesse caso, pulamos aqui:

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;

A variável local está agora definida e o próximo opcode está pronto para execução. O Python continua pelo iterável até chegar ao fim, fazendo a previsão bem-sucedida a cada vez.

A página wiki do Python tem mais informações sobre como a máquina virtual do CPython funciona.

Alex Riley
fonte
Atualização secundária: a partir do CPython 3.6, as economias de previsão diminuem um pouco; em vez de dois ramos imprevisíveis, existe apenas um. A alteração ocorre devido à mudança do bytecode para o wordcode ; agora todos os "códigos de palavras" têm um argumento, é zerado quando a instrução não aceita logicamente um argumento. Portanto, o HAS_ARGteste nunca ocorre (exceto quando o rastreamento de baixo nível é ativado no momento da compilação e do tempo de execução, o que nenhuma compilação normal faz), deixando apenas um salto imprevisível.
ShadowRanger
Mesmo esse salto imprevisível não ocorre na maioria das compilações do CPython, devido ao novo ( como no Python 3.1 , ativado por padrão no 3.2 ) comportamento de gotos computados; quando usada, a PREDICTmacro está completamente desativada; em vez disso, a maioria dos casos termina em uma DISPATCHramificação diretamente. Mas nas CPUs de previsão de ramificação, o efeito é semelhante ao de PREDICT, uma vez que ramificação (e previsão) é por código de operação, aumentando as chances de previsão de ramificação bem-sucedida.
ShadowRanger