"X <y <z" é mais rápido que "x <y e y <z"?

129

A partir desta página , sabemos que:

As comparações encadeadas são mais rápidas que o uso do andoperador. Escreva em x < y < zvez de x < y and y < z.

No entanto, obtive um resultado diferente testando os seguintes trechos de código:

$ python -m timeit "x = 1.2" "y = 1.3" "z = 1.8" "x < y < z"
1000000 loops, best of 3: 0.322 usec per loop
$ python -m timeit "x = 1.2" "y = 1.3" "z = 1.8" "x < y and y < z"
1000000 loops, best of 3: 0.22 usec per loop
$ python -m timeit "x = 1.2" "y = 1.3" "z = 1.1" "x < y < z"
1000000 loops, best of 3: 0.279 usec per loop
$ python -m timeit "x = 1.2" "y = 1.3" "z = 1.1" "x < y and y < z"
1000000 loops, best of 3: 0.215 usec per loop

Parece que x < y and y < zé mais rápido que x < y < z. Por quê?

Depois de pesquisar algumas postagens neste site (como esta ), eu sei que "avaliado apenas uma vez" é a chave x < y < z, mas ainda estou confuso. Para fazer um estudo mais aprofundado, desmontei essas duas funções usando dis.dis:

import dis

def chained_compare():
        x = 1.2
        y = 1.3
        z = 1.1
        x < y < z

def and_compare():
        x = 1.2
        y = 1.3
        z = 1.1
        x < y and y < z

dis.dis(chained_compare)
dis.dis(and_compare)

E a saída é:

## chained_compare ##

  4           0 LOAD_CONST               1 (1.2)
              3 STORE_FAST               0 (x)

  5           6 LOAD_CONST               2 (1.3)
              9 STORE_FAST               1 (y)

  6          12 LOAD_CONST               3 (1.1)
             15 STORE_FAST               2 (z)

  7          18 LOAD_FAST                0 (x)
             21 LOAD_FAST                1 (y)
             24 DUP_TOP
             25 ROT_THREE
             26 COMPARE_OP               0 (<)
             29 JUMP_IF_FALSE_OR_POP    41
             32 LOAD_FAST                2 (z)
             35 COMPARE_OP               0 (<)
             38 JUMP_FORWARD             2 (to 43)
        >>   41 ROT_TWO
             42 POP_TOP
        >>   43 POP_TOP
             44 LOAD_CONST               0 (None)
             47 RETURN_VALUE

## and_compare ##

 10           0 LOAD_CONST               1 (1.2)
              3 STORE_FAST               0 (x)

 11           6 LOAD_CONST               2 (1.3)
              9 STORE_FAST               1 (y)

 12          12 LOAD_CONST               3 (1.1)
             15 STORE_FAST               2 (z)

 13          18 LOAD_FAST                0 (x)
             21 LOAD_FAST                1 (y)
             24 COMPARE_OP               0 (<)
             27 JUMP_IF_FALSE_OR_POP    39
             30 LOAD_FAST                1 (y)
             33 LOAD_FAST                2 (z)
             36 COMPARE_OP               0 (<)
        >>   39 POP_TOP
             40 LOAD_CONST               0 (None)

Parece que o x < y and y < zcomando tem menos disfarces do que x < y < z. Devo considerar x < y and y < zmais rápido do que x < y < z?

Testado com Python 2.7.6 em uma CPU Intel (R) Xeon (E) E5640 a 2.67GHz.

zangw
fonte
8
Comandos mais desmontados não significam mais complexidade nem código mais lento. No entanto, vendo seus timeittestes, fiquei interessado nisso.
Marco Bonelli
6
Presumi que a diferença de velocidade de "avaliado uma vez" ocorre quando ynão é apenas uma pesquisa variável, mas um processo mais caro, como uma chamada de função? Ou seja, 10 < max(range(100)) < 15é mais rápido do que 10 < max(range(100)) and max(range(100)) < 15porque max(range(100))é chamado uma vez para as duas comparações.
Zehnpaard 01/12/2015
2
@MarcoBonelli É o que acontece quando o código desmontado 1) não contém loops e 2) todos os bytecodes são muito muito rápidos, porque nesse ponto a sobrecarga do mainloop se torna significativa.
Bakuriu
2
A previsão de ramificação pode atrapalhar seus testes.
Corey Ogburn #
2
@zehnpaard Eu concordo com você. Quando "y" é mais do que um valor simples (por exemplo, uma chamada de função ou cálculo), eu esperaria que o fato de "y" ser avaliado uma vez em x <y <z tenha um impacto muito mais perceptível. No caso apresentado, estamos dentro das barras de erro: os efeitos de previsão de falha (com falha), bytecode abaixo do ideal e outros efeitos específicos da plataforma / CPU dominam. Isso não invalida a regra geral de que avaliar uma vez é melhor (além de mais legível), mas mostra que isso pode não agregar tanto valor nos extremos.
MartyMacGyver

Respostas:

111

A diferença é que in x < y < z yé avaliado apenas uma vez. Isso não faz grande diferença se y é uma variável, mas ocorre quando é uma chamada de função, que leva algum tempo para ser computada.

from time import sleep
def y():
    sleep(.2)
    return 1.3
%timeit 1.2 < y() < 1.8
10 loops, best of 3: 203 ms per loop
%timeit 1.2 < y() and y() < 1.8
1 loops, best of 3: 405 ms per loop
Roubar
fonte
18
Claro, também pode haver uma diferença semântica. Não apenas y () poderia retornar dois valores diferentes, mas com uma variável a avaliação do operador menor que em x <y poderia alterar o valor de y. É por isso que muitas vezes não é otimizado afastado no código de byte (se y é uma variável não-local ou uma participação em um fecho, por exemplo)
Random832
Apenas curioso, por que você precisava de uma sleep()função interna?
Prof
@Prof Isso simula uma função que leva algum tempo para calcular o resultado. Se as funções retornarem imediatamente, não haverá muita diferença entre os dois resultados.
Rob
@Rob Por que não haveria muita diferença? Seria 3ms vs 205ms, o que demonstra bem o suficiente, não é?
Prof
@Prof Está faltando o ponto em que y () é calculado duas vezes, ou seja, 2x200ms em vez de 1x200ms. O restante (3/5 ms) é um ruído irrelevante na medição do tempo.
Rob
22

O bytecode ideal para as duas funções que você definiu seria

          0 LOAD_CONST               0 (None)
          3 RETURN_VALUE

porque o resultado da comparação não é usado. Vamos tornar a situação mais interessante retornando o resultado da comparação. Vamos também fazer com que o resultado não seja conhecido em tempo de compilação.

def interesting_compare(y):
    x = 1.1
    z = 1.3
    return x < y < z  # or: x < y and y < z

Novamente, as duas versões da comparação são semanticamente idênticas; portanto, o bytecode ideal é o mesmo para as duas construções. O melhor que posso resolver, seria assim. Anotei cada linha com o conteúdo da pilha antes e depois de cada código de operação, na notação Forth (topo da pilha à direita, --divide antes e depois, o trailing ?indica algo que pode ou não estar lá). Observe que RETURN_VALUEdescarta tudo o que é deixado na pilha abaixo do valor retornado.

          0 LOAD_FAST                0 (y)    ;          -- y
          3 DUP_TOP                           ; y        -- y y
          4 LOAD_CONST               0 (1.1)  ; y y      -- y y 1.1
          7 COMPARE_OP               4 (>)    ; y y 1.1  -- y pred
         10 JUMP_IF_FALSE_OR_POP     19       ; y pred   -- y
         13 LOAD_CONST               1 (1.3)  ; y        -- y 1.3
         16 COMPARE_OP               0 (<)    ; y 1.3    -- pred
     >>  19 RETURN_VALUE                      ; y? pred  --

Se uma implementação da linguagem, CPython, PyPy, qualquer que seja, não gera esse bytecode (ou sua própria sequência equivalente de operações) para ambas as variações, isso demonstra a má qualidade desse compilador de bytecode . Obter as seqüências de bytecode postadas acima é um problema resolvido (acho que tudo o que você precisa para este caso é dobragem constante , eliminação de código morto e melhor modelagem do conteúdo da pilha; eliminação comum de subexpressão também seria barata e valiosa ) e realmente não há desculpa para não fazer isso em uma implementação de linguagem moderna.

Agora, acontece que todas as implementações atuais da linguagem possuem compiladores de bytecode de baixa qualidade. Mas você deve ignorar isso enquanto codifica! Finja que o compilador de bytecode é bom e escreva o código mais legível . Provavelmente será bastante rápido de qualquer maneira. Caso contrário, procure primeiro as melhorias algorítmicas e tente o Cython em segundo lugar - isso fornecerá muito mais melhorias para o mesmo esforço do que qualquer ajuste no nível da expressão que você possa aplicar.

zwol
fonte
Bem, a mais importante de todas as otimizações teria que ser possível em primeiro lugar: inlining. E isso está longe de ser um "problema resolvido" para linguagens dinâmicas que permitem alterar dinamicamente a implementação de uma função (factível - o HotSpot pode fazer coisas semelhantes e coisas como a Graal está trabalhando para tornar esse tipo de otimização disponível para Python e outras linguagens dinâmicas ) E como a função em si pode ser chamada de módulos diferentes (ou uma chamada pode ser gerada dinamicamente!), Você realmente não pode fazer essas otimizações lá.
Voo
1
@Voo Meu bytecode otimizado para mão tem exatamente a mesma semântica que o original, mesmo na presença de dinamismo arbitrário (uma exceção: a <b ≡ b> a é assumida). Além disso, o embutimento é superestimado. Se você faz muito disso - e é muito fácil fazer muito disso - você explode o cache I e perde tudo o que ganhou e mais alguns.
Zwol 01/12/2015
Você está certo, pensei que queria dizer que queria otimizar seu interesting_comparecódigo de bytes simples na parte superior (que funcionaria apenas com inlining). Completamente offtopic mas: Inlining é uma das otimizações mais essenciais para qualquer compilador. Você pode tentar executar alguns benchmarks com, digamos, HotSpot em programas reais (não em alguns testes de matemática que gastam 99% de seu tempo em um hot loop otimizado manualmente [e, portanto, não possui mais chamadas de funções]) quando você desativa o capacidade de alinhar qualquer coisa - você verá grandes regressões.
Voo
Sim, o bytecode simples no topo deveria ser a "versão ideal" do código original do OP, não interesting_compare.
zwol
"uma exceção: a <b ≡ b> a é assumida" → o que simplesmente não é verdade no Python. Além disso, acho que o CPython não pode realmente assumir operações de ynão alterar a pilha, pois possui muitas ferramentas de depuração.
Veedrac
8

Como a diferença na saída parece ser devido à falta de otimização, acho que você deve ignorá-la na maioria dos casos - pode ser que a diferença vá embora. A diferença é que ysó deve ser avaliada uma vez e isso é resolvido duplicando-o na pilha, o que requer um extra POP_TOP- a solução a ser usadaLOAD_FAST ser pode ser possível.

A diferença importante, porém, é que no x<y and y<zsegundo ydeve ser avaliada duas vezes se for x<yavaliada como verdadeira, isso tem implicações se a avaliação dey demorar um tempo considerável ou tiver efeitos colaterais.

Na maioria dos cenários, você deve usar, x<y<zapesar de ser um pouco mais lento.

skyking
fonte
6

Primeiro de tudo, sua comparação é praticamente sem sentido, porque as duas construções diferentes não foram introduzidas para fornecer uma melhoria de desempenho; portanto, você não deve decidir se deve usar uma no lugar da outra com base nisso.

A x < y < zconstrução:

  1. É mais claro e mais direto em seu significado.
  2. Sua semântica é o que você esperaria de o "significado matemático" da comparação: evalute x, ye z uma vez e verifique se toda a condição segura. O uso andaltera a semântica avaliando yvárias vezes, o que pode alterar o resultado .

Portanto, escolha um no lugar do outro, dependendo da semântica desejada e, se forem equivalentes, se um é mais legível que o outro.

Dito isto: um código mais desmontado não implica um código mais lento. No entanto, executar mais operações de bytecode significa que cada operação é mais simples e, no entanto, requer uma iteração do loop principal. Isso significa que, se as operações que você estiver executando forem extremamente rápidas (por exemplo, pesquisa de variável local como você está fazendo lá), a sobrecarga de execução de mais operações de bytecode poderá ser importante.

Mas observe que esse resultado não se aplica à situação mais genérica, apenas ao "pior caso" que você perfil. Como outros observaram, se você mudary para algo que leva um pouco mais de tempo, verá que os resultados mudam, porque a notação encadeada o avalia apenas uma vez.

Resumindo:

  • Considere a semântica antes do desempenho.
  • Leve em conta a legibilidade.
  • Não confie em micro benchmarks. Sempre faça um perfil com diferentes tipos de parâmetros para ver como um tempo de função / expressão se comporta em relação aos parâmetros mencionados e considere como você planeja usá-lo.
Bakuriu
fonte
5
Acho que sua resposta não inclui o fato direto e relevante de que a página citada, no caso particular da pergunta - comparação de flutuadores, está simplesmente errada. A comparação encadeada não é mais rápida, como visto nas duas medidas e no código de código gerado.
Pvg
30
Responder a uma pergunta marcada com desempenho com "talvez você não devesse pensar tanto em desempenho" não me parece útil. Você está fazendo suposições potencialmente paternalistas sobre a compreensão dos princípios gerais de programação do questionador e, em seguida, falando principalmente sobre eles, em vez do assunto em questão.
Ben Millwood
@ Leerdac você está interpretando mal o comentário. A otimização proposta no documento original em que o OP confiava está errada, no caso de carros alegóricos no mínimo. Não é mais rápido.
pvg 08/12/15