Perché il ritorno anticipato è più lento di altri?


180

Questa è una domanda di follow-up a una risposta che ho dato qualche giorno fa . Modifica: sembra che l'OP di quella domanda abbia già usato il codice che gli ho inviato per porre la stessa domanda , ma non ne ero a conoscenza. Scuse. Le risposte fornite sono diverse però!

Sostanzialmente ho osservato che:

>>> def without_else(param=False):
...     if param:
...         return 1
...     return 0
>>> def with_else(param=False):
...     if param:
...         return 1
...     else:
...         return 0
>>> from timeit import Timer as T
>>> T(lambda : without_else()).repeat()
[0.3011460304260254, 0.2866089344024658, 0.2871549129486084]
>>> T(lambda : with_else()).repeat()
[0.27536892890930176, 0.2693932056427002, 0.27011704444885254]
>>> T(lambda : without_else(True)).repeat()
[0.3383951187133789, 0.32756996154785156, 0.3279120922088623]
>>> T(lambda : with_else(True)).repeat()
[0.3305950164794922, 0.32186388969421387, 0.3209099769592285]

... o in altre parole: avere la elseclausola è più veloce indipendentemente dalla ifcondizione innescata o meno.

Presumo che abbia a che fare con diversi bytecode generati dai due, ma qualcuno è in grado di confermare / spiegare in dettaglio?

EDIT: Sembra che non tutti siano in grado di riprodurre i miei tempi, quindi ho pensato che potesse essere utile fornire alcune informazioni sul mio sistema. Sto eseguendo Ubuntu 11.10 a 64 bit con il python predefinito installato. pythongenera le seguenti informazioni sulla versione:

Python 2.7.2+ (default, Oct  4 2011, 20:06:09) 
[GCC 4.6.1] on linux2

Ecco i risultati dello smontaggio in Python 2.7:

>>> dis.dis(without_else)
  2           0 LOAD_FAST                0 (param)
              3 POP_JUMP_IF_FALSE       10

  3           6 LOAD_CONST               1 (1)
              9 RETURN_VALUE        

  4     >>   10 LOAD_CONST               2 (0)
             13 RETURN_VALUE        
>>> dis.dis(with_else)
  2           0 LOAD_FAST                0 (param)
              3 POP_JUMP_IF_FALSE       10

  3           6 LOAD_CONST               1 (1)
              9 RETURN_VALUE        

  5     >>   10 LOAD_CONST               2 (0)
             13 RETURN_VALUE        
             14 LOAD_CONST               0 (None)
             17 RETURN_VALUE        

1
c'era una domanda identica su SO che non riesco a trovare ora. Hanno verificato il bytecode generato e c'è stato un ulteriore passaggio. Le differenze osservate dipendevano molto dal tester (macchina, SO ..), a volte trovando differenze molto piccole.
Joaquin,

3
Su 3.x, entrambi producono un identico bytecode salvo un codice irraggiungibile ( LOAD_CONST(None); RETURN_VALUE- ma come detto, non viene mai raggiunto) alla fine di with_else. Dubito fortemente che il codice morto renda più veloce una funzione. Qualcuno potrebbe fornire un dissu 2.7?

4
Non sono riuscito a riprodurlo. Funzionava con elseed Falseera il più lento di tutti (152 ns). Il secondo più veloce è stato Truesenza else(143ns) e altri due erano sostanzialmente gli stessi (137ns e 138ns). Non ho usato il parametro predefinito e l'ho misurato con %timeitin iPython.
rplnt,

Non riesco a riprodurre quei tempi, a volte with_else è più veloce, a volte questa è la versione without_else, sembra che siano abbastanza simili per me ...
Cédric Julien

1
Aggiunti risultati di smontaggio. Sto usando Ubuntu 11.10, 64 bit, stock Python 2.7 - stessa configurazione di @mac. Concordo anche che with_elseè notevolmente più veloce.
Chris Morgan,

Risposte:


387

Questa è una supposizione pura, e non ho trovato un modo semplice per verificare se è giusto, ma ho una teoria per te.

Ho provato il tuo codice e ottengo lo stesso risultato, without_else()è più volte leggermente più lento di with_else():

>>> T(lambda : without_else()).repeat()
[0.42015745017874906, 0.3188967452567226, 0.31984281521812363]
>>> T(lambda : with_else()).repeat()
[0.36009842032996175, 0.28962249392031936, 0.2927151355828528]
>>> T(lambda : without_else(True)).repeat()
[0.31709728471076915, 0.3172671387005721, 0.3285821242644147]
>>> T(lambda : with_else(True)).repeat()
[0.30939889008243426, 0.3035132258429485, 0.3046679117038593]

Considerando che il bytecode è identico, l'unica differenza è il nome della funzione. In particolare il test di temporizzazione esegue una ricerca sul nome globale. Prova a rinominare without_else()e la differenza scompare:

>>> def no_else(param=False):
    if param:
        return 1
    return 0

>>> T(lambda : no_else()).repeat()
[0.3359846013948413, 0.29025818923918223, 0.2921801513879245]
>>> T(lambda : no_else(True)).repeat()
[0.3810395594970828, 0.2969634408842694, 0.2960104566362247]

La mia ipotesi è che without_elseabbia una collisione hash con qualcos'altro in globals()modo che la ricerca globale del nome sia leggermente più lenta.

Modifica : un dizionario con 7 o 8 tasti ha probabilmente 32 slot, quindi su quella base without_elseha una collisione hash con __builtins__:

>>> [(k, hash(k) % 32) for k in globals().keys() ]
[('__builtins__', 8), ('with_else', 9), ('__package__', 15), ('without_else', 8), ('T', 21), ('__name__', 25), ('no_else', 28), ('__doc__', 29)]

Per chiarire come funziona l'hash:

__builtins__ gli hash a -1196389688 che hanno ridotto il modulo della dimensione della tabella (32) indicano che è memorizzato nello slot n. 8 della tabella.

without_elsehash a 505688136 che ha ridotto il modulo 32 di 8, quindi c'è una collisione. Per risolvere questo Python calcola:

Iniziare con:

j = hash % 32
perturb = hash

Ripeti fino a quando non troviamo uno slot libero:

j = (5*j) + 1 + perturb;
perturb >>= 5;
use j % 2**i as the next table index;

che gli dà 17 da usare come indice successivo. Fortunatamente è gratuito, quindi il ciclo si ripete solo una volta. La dimensione della tabella hash è una potenza di 2, quindi 2**iè la dimensione della tabella hash, iè il numero di bit utilizzati dal valore hash j.

Ogni sonda nella tabella può trovare una di queste:

  • Lo slot è vuoto, in tal caso il sondaggio si interrompe e sappiamo che il valore non è nella tabella.

  • Lo slot non è utilizzato ma è stato utilizzato in passato, nel qual caso andiamo a provare il valore successivo calcolato come sopra.

  • Lo slot è pieno ma l'intero valore di hash memorizzato nella tabella non è lo stesso dell'hash della chiave che stiamo cercando (questo è ciò che accade nel caso di __builtins__vs without_else).

  • Lo slot è pieno e ha esattamente il valore di hash che vogliamo, quindi Python controlla se la chiave e l'oggetto che stiamo cercando sono lo stesso oggetto (che in questo caso saranno perché stringhe brevi che potrebbero essere identificatori sono internate così identificatori identici usano la stessa stringa esatta).

  • Alla fine quando lo slot è pieno, l'hash corrisponde esattamente, ma i tasti non sono l'oggetto identico, quindi e solo allora Python proverà a confrontarli per l'uguaglianza. Questo è relativamente lento, ma nel caso delle ricerche dei nomi non dovrebbe effettivamente avvenire.


9
@Chris, no la lunghezza della stringa non dovrebbe essere significativa. La prima volta che si esegue l'hashing di una stringa ci vorrà del tempo proporzionale alla lunghezza della stringa, ma l'hash calcolato viene memorizzato nella cache nell'oggetto stringa, quindi gli hash successivi sono O (1).
Duncan,

1
Ah ok, non ero a conoscenza della memorizzazione nella cache, ma ha senso
Chris Eberle,

9
Affascinante! Posso chiamarti Sherlock? ;) Spero comunque che non dimenticherò di darti alcuni punti aggiuntivi con una taglia non appena la domanda sarà ammissibile.
Voo,

4
@mac, non del tutto. Aggiungerò un po 'di risoluzione dell'hash (lo avrei inserito nel commento ma è più interessante di quanto pensassi).
Duncan,

6
@Duncan - Grazie mille per aver dedicato del tempo a illustrare il processo di hash. La migliore risposta! :)
mac,
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.