“X <y <z” è più veloce di “x <yey <z”?


129

Da questa pagina , sappiamo che:

I confronti concatenati sono più rapidi rispetto all'utilizzo anddell'operatore. Scrivi x < y < zinvece dix < y and y < z .

Tuttavia, ho ottenuto un risultato diverso testando i seguenti frammenti di codice:

$ 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

Sembra che x < y and y < zsia più veloce di x < y < z.Perché?

Dopo aver cercato alcuni post in questo sito (come questo ), so che "valutato solo una volta" è la chiave x < y < z, tuttavia sono ancora confuso. Per approfondire, ho smontato queste due funzioni 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 l'output è:

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

Sembra che il x < y and y < zcomando abbia meno dissimulato di x < y < z. Dovrei considerare x < y and y < zpiù velocemente di x < y < z?

Testato con Python 2.7.6 su una CPU Intel (R) Xeon (R) E5640 a 2,67 GHz.


8
Comandi più disassemblati non significano più complessità codice più lento. Tuttavia, vedendo i tuoi timeittest, mi sono interessato a questo.
Marco Bonelli,

6
Ho assunto la differenza di velocità rispetto a "valutata una volta" quando ynon si tratta solo di una ricerca variabile, ma di un processo più costoso come una chiamata di funzione? Cioè 10 < max(range(100)) < 15è più veloce di 10 < max(range(100)) and max(range(100)) < 15perché max(range(100))viene chiamato una volta per entrambi i confronti.
Zehnpaard,

2
@MarcoBonelli Lo fa quando il codice smontato 1) non contiene loop e 2) ogni singolo bytecode è molto molto veloce, perché a quel punto il sovraccarico del mainloop diventa significativo.
Bakuriu,

2
La previsione del ramo potrebbe incasinare i test.
Corey Ogburn,

2
@zehnpaard Sono d'accordo con te. Quando "y" è più di un semplice valore (ad esempio, una chiamata di funzione o un calcolo), mi aspetto che il fatto che "y" venga valutato una volta in x <y <z abbia un impatto molto più evidente. Nel caso presentato siamo all'interno delle barre di errore: prevalgono gli effetti della previsione del ramo (non riuscita), il bytecode non ottimale e altri effetti specifici della piattaforma / CPU. Ciò non invalida la regola generale secondo cui valutare una volta è meglio (oltre ad essere più leggibile), ma mostra che ciò potrebbe non aggiungere tanto valore agli estremi.
MartyMacGyver il

Risposte:


111

La differenza è che in x < y < z yviene valutato solo una volta. Questo non fa una grande differenza se y è una variabile, ma lo fa quando si tratta di una chiamata di funzione, che richiede del tempo per il calcolo.

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

18
Certo, potrebbe esserci anche una differenza semantica. Non solo y () potrebbe restituire due valori diversi, ma con una variabile la valutazione dell'operatore minore di in x <y potrebbe modificare il valore di y. Questo è il motivo per cui spesso non viene ottimizzato nel codice byte (se y è una variabile non locale o partecipa a una chiusura, per esempio)
Casuale 832

Solo curioso, perché hai bisogno di una sleep()funzione interna?
Prof

@Prof Vale a simulare una funzione che richiede del tempo per calcolare il risultato. Se le funzioni ritornano immediatamente, non ci sarà molta differenza tra i due risultati timeit.
Rob,

@Rob Perché non ci sarebbe molta differenza? Sarebbe 3ms contro 205ms, questo lo dimostra abbastanza bene, no?
Prof

@Prof Manca il punto in cui y () viene calcolato due volte, quindi è 2x200ms anziché 1x200ms. Il resto (3/5 ms) è un rumore irrilevante nella misurazione della temporizzazione.
Rob,

22

Il bytecode ottimale per entrambe le funzioni definite sarebbe

          0 LOAD_CONST               0 (None)
          3 RETURN_VALUE

perché il risultato del confronto non viene utilizzato. Rendiamo la situazione più interessante restituendo il risultato del confronto. Facciamo anche in modo che il risultato non sia conoscibile al momento della compilazione.

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

Ancora una volta, le due versioni del confronto sono semanticamente identiche, quindi il bytecode ottimale è lo stesso per entrambi i costrutti. Come meglio riesco a capire, sembrerebbe così. Ho annotato ogni riga con il contenuto dello stack prima e dopo ogni codice operativo, nella notazione Forth (parte superiore dello stack a destra, --divide prima e dopo, il trailing ?indica qualcosa che potrebbe o non potrebbe essere lì). Si noti che RETURN_VALUEscarta tutto ciò che accade di essere lasciato nello stack sotto il valore restituito.

          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 un'implementazione del linguaggio, CPython, PyPy, qualunque cosa, non generi questo bytecode (o la sua sequenza equivalente di operazioni) per entrambe le varianti, ciò dimostra la scarsa qualità di quel compilatore bytecode . Ottenere dalle sequenze di bytecode che hai postato sopra è un problema risolto (penso che tutto ciò di cui hai bisogno in questo caso sia la piegatura costante , l' eliminazione del codice morto e una migliore modellizzazione del contenuto dello stack; l' eliminazione comune della sottoespressione sarebbe anche economica e preziosa ) e non ci sono davvero scuse per non farlo in una moderna implementazione del linguaggio.

Ora, succede che tutte le attuali implementazioni del linguaggio abbiano compilatori bytecode di scarsa qualità. Ma dovresti ignorarlo mentre scrivi! Fai finta che il compilatore bytecode sia buono e scrivi il codice più leggibile . Probabilmente sarà comunque abbastanza veloce. In caso contrario, cerca prima i miglioramenti algoritmici e prova Cython in un secondo momento, che fornirà molti più miglioramenti per lo stesso sforzo di qualsiasi modifica a livello di espressione che potresti applicare.


Bene, la più importante di tutte le ottimizzazioni dovrebbe essere possibile in primo luogo: l'allineamento. E questo è ben lungi dall'essere un "problema risolto" per i linguaggi dinamici che consentono di modificare in modo dinamico l'implementazione di una funzione (fattibile però - HotSpot può fare cose simili e cose come Graal stanno lavorando per rendere questo tipo di ottimizzazioni disponibili per Python e altri linguaggi dinamici ). E poiché la funzione stessa potrebbe essere chiamata da diversi moduli (o una chiamata potrebbe essere generata dinamicamente!) Non puoi davvero fare queste ottimizzazioni lì.
Voo

1
@Voo Il mio bytecode ottimizzato a mano ha esattamente la stessa semantica dell'originale anche in presenza di un dinamismo arbitrario (si presume un'eccezione: a <b ≡ b> a). Inoltre, l'allineamento è sopravvalutato. Se fai troppo - ed è troppo facile farne troppo - fai saltare l'I-cache e perdi tutto quello che hai guadagnato e poi alcuni.
zwol,

Hai ragione, pensavo volessi dire che volevi ottimizzare il tuo interesting_comparesemplice bytecode in alto (che avrebbe funzionato solo con l'inline). Completamente offtopico ma: Inlining è una delle ottimizzazioni più essenziali per qualsiasi compilatore. Puoi provare a eseguire alcuni benchmark con HotSpot su programmi reali (non alcuni test di matematica che trascorrono il 99% del loro tempo in un hot loop ottimizzato a mano [e quindi non ha più chiamate di funzione in alcun modo]) quando disabiliti il ​​suo capacità di incorporare qualsiasi cosa: vedrai grandi regressioni.
Voo

@Voo Sì, il semplice bytecode in alto doveva essere la "versione ottimale" del codice originale dell'OP, no interesting_compare.
zwol,

"un'eccezione: si presuppone a <b ≡ b> a" → che semplicemente non è vero in Python. Inoltre, penso che CPython non possa nemmeno davvero supporre che le operazioni su ynon cambino lo stack, dal momento che ha molti strumenti di debug.
Veedrac,

8

Poiché la differenza nell'output sembra essere dovuta alla mancanza di ottimizzazione, penso che dovresti ignorare quella differenza nella maggior parte dei casi - potrebbe essere che la differenza scompaia. La differenza è perché ydovrebbe essere valutata una sola volta e questo viene risolto duplicandolo nello stack che richiede un extra POP_TOP: la soluzione da usareLOAD_FAST potrebbe essere possibile però.

La differenza importante però è che nel x<y and y<zsecondo ydovrebbe essere valutato due volte se si x<yvaluta vero, questo ha implicazioni se la valutazione diy richiede molto tempo o ha effetti collaterali.

Nella maggior parte degli scenari dovresti usare x<y<znonostante sia un po 'più lento.


6

Prima di tutto, il tuo confronto è praticamente insignificante perché i due diversi costrutti non sono stati introdotti per fornire un miglioramento delle prestazioni, quindi non dovresti decidere se usarne uno al posto dell'altro in base a quello.

Il x < y < zcostrutto:

  1. È più chiaro e più diretto nel suo significato.
  2. La sua semantica è ciò che ti aspetteresti dal "significato matematico" del confronto: valutare x, ye z una volta e verificare se l'intera condizione è valida. L'uso andcambia la semantica valutando ypiù volte, il che può cambiare il risultato .

Quindi scegli uno al posto dell'altro a seconda della semantica che desideri e, se sono equivalenti, se uno è più leggibile dell'altro.

Detto questo: un codice più disassemblato non implica un codice più lento. Tuttavia, l'esecuzione di più operazioni bytecode significa che ogni operazione è più semplice e tuttavia richiede un'iterazione del ciclo principale. Ciò significa che se le operazioni che si stanno eseguendo sono estremamente veloci (ad es. Ricerca di variabili locali mentre ci si sta eseguendo lì), il sovraccarico di eseguire più operazioni con bytecode può essere importante.

Ma si noti che questo risultato non non tenere nella situazione più generico, solo per il "caso peggiore" che vi capita di profilo. Come altri hanno notato, se cambiy passa a qualcosa che richiede ancora un po 'più di tempo vedrai che i risultati cambiano, perché la notazione concatenata lo valuta solo una volta.

riassumendo:

  • Considera la semantica prima dell'esibizione.
  • Prendi in considerazione la leggibilità.
  • Non fidarti dei micro benchmark. Profila sempre con diversi tipi di parametri per vedere come si comporta un timing di funzione / espressione in relazione a detti parametri e considera come pensi di usarlo.

5
Penso che la tua risposta non includa il fatto semplice e pertinente che la pagina citata, nel caso particolare della domanda - confrontare i float, sia semplicemente sbagliata. Il confronto incatenato non è più veloce come visto in entrambe le misurazioni e nel bytecode generato.
pvg

30
Rispondere a una domanda taggata performance con "forse non dovresti pensare così tanto alla performance" non mi sembra utile. Stai facendo ipotesi potenzialmente condiscendenti circa la comprensione dei principi generali di programmazione da parte dell'interrogante e quindi stai principalmente parlando di loro invece del problema in questione.
Ben Millwood,

@Veerdac stai leggendo male il commento. L'ottimizzazione proposta nel documento originale su cui si basava l'OP è errata, nel caso dei galleggianti come minimo. Non è più veloce
pvg
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.