Perché la sottoclasse in Python rallenta così tanto le cose?


13

Stavo lavorando su una semplice classe che si estende dicte mi sono reso conto che la ricerca e l'uso dei tasti picklesono molto lenti.

Ho pensato che fosse un problema con la mia classe, quindi ho fatto alcuni banali benchmark:

(venv) marco@buzz:~/sources/python-frozendict/test$ python --version
Python 3.9.0a0
(venv) marco@buzz:~/sources/python-frozendict/test$ sudo pyperf system tune --affinity 3
[sudo] password for marco: 
Tune the system configuration to run benchmarks

Actions
=======

CPU Frequency: Minimum frequency of CPU 3 set to the maximum frequency

System state
============

CPU: use 1 logical CPUs: 3
Perf event: Maximum sample rate: 1 per second
ASLR: Full randomization
Linux scheduler: No CPU is isolated
CPU Frequency: 0-3=min=max=2600 MHz
CPU scaling governor (intel_pstate): performance
Turbo Boost (intel_pstate): Turbo Boost disabled
IRQ affinity: irqbalance service: inactive
IRQ affinity: Default IRQ affinity: CPU 0-2
IRQ affinity: IRQ affinity: IRQ 0,2=CPU 0-3; IRQ 1,3-17,51,67,120-131=CPU 0-2
Power supply: the power cable is plugged

Advices
=======

Linux scheduler: Use isolcpus=<cpu list> kernel parameter to isolate CPUs
Linux scheduler: Use rcu_nocbs=<cpu list> kernel parameter (with isolcpus) to not schedule RCU on isolated CPUs
(venv) marco@buzz:~/sources/python-frozendict/test$ python -m pyperf timeit --rigorous --affinity 3 -s '                    
x = {0:0, 1:1, 2:2, 3:3, 4:4}
' 'x[4]'
.........................................
Mean +- std dev: 35.2 ns +- 1.8 ns
(venv) marco@buzz:~/sources/python-frozendict/test$ python -m pyperf timeit --rigorous --affinity 3 -s '
class A(dict):
    pass             

x = A({0:0, 1:1, 2:2, 3:3, 4:4})
' 'x[4]'
.........................................
Mean +- std dev: 60.1 ns +- 2.5 ns
(venv) marco@buzz:~/sources/python-frozendict/test$ python -m pyperf timeit --rigorous --affinity 3 -s '
x = {0:0, 1:1, 2:2, 3:3, 4:4}
' '5 in x'
.........................................
Mean +- std dev: 31.9 ns +- 1.4 ns
(venv) marco@buzz:~/sources/python-frozendict/test$ python -m pyperf timeit --rigorous --affinity 3 -s '
class A(dict):
    pass

x = A({0:0, 1:1, 2:2, 3:3, 4:4})
' '5 in x'
.........................................
Mean +- std dev: 64.7 ns +- 5.4 ns
(venv) marco@buzz:~/sources/python-frozendict/test$ python
Python 3.9.0a0 (heads/master-dirty:d8ca2354ed, Oct 30 2019, 20:25:01) 
[GCC 9.2.1 20190909] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from timeit import timeit
>>> class A(dict):
...     def __reduce__(self):                 
...         return (A, (dict(self), ))
... 
>>> timeit("dumps(x)", """
... from pickle import dumps
... x = {0:0, 1:1, 2:2, 3:3, 4:4}
... """, number=10000000)
6.70694484282285
>>> timeit("dumps(x)", """
... from pickle import dumps
... x = A({0:0, 1:1, 2:2, 3:3, 4:4})
... """, number=10000000, globals={"A": A})
31.277778962627053
>>> timeit("loads(x)", """
... from pickle import dumps, loads
... x = dumps({0:0, 1:1, 2:2, 3:3, 4:4})
... """, number=10000000)
5.767975459806621
>>> timeit("loads(x)", """
... from pickle import dumps, loads
... x = dumps(A({0:0, 1:1, 2:2, 3:3, 4:4}))
... """, number=10000000, globals={"A": A})
22.611666693352163

I risultati sono davvero una sorpresa. Mentre la ricerca dei tasti è 2 volte più lenta, pickleè 5 volte più lenta.

Come può essere? Altri metodi, come get(), __eq__()e __init__(), e iterazione keys(), values()e items()sono veloci come dict.


EDIT : ho dato un'occhiata al codice sorgente di Python 3.9, e Objects/dictobject.csembra che il __getitem__()metodo sia implementato da dict_subscript(). E dict_subscript()rallenta le sottoclassi solo se manca la chiave, poiché la sottoclasse può implementare __missing__()e prova a vedere se esiste. Ma il benchmark era con una chiave esistente.

Ma ho notato qualcosa: __getitem__()è definito con la bandiera METH_COEXIST. Inoltre __contains__(), l'altro metodo che è 2x più lento, ha lo stesso flag. Dalla documentazione ufficiale :

Il metodo verrà caricato al posto delle definizioni esistenti. Senza METH_COEXIST, l'impostazione predefinita è saltare definizioni ripetute. Dato che i wrapper di slot vengono caricati prima della tabella dei metodi, l'esistenza di uno slot sq_contains, ad esempio, genererebbe un metodo di wrapping chiamato contiene () e precluderebbe il caricamento di una corrispondente PyCFunction con lo stesso nome. Con il flag definito, PyCFunction verrà caricato al posto dell'oggetto wrapper e coesisterà con lo slot. Questo è utile perché le chiamate a PyCFunctions sono ottimizzate più delle chiamate di oggetti wrapper.

Quindi, se ho capito bene, in teoria METH_COEXISTdovrebbe accelerare le cose, ma sembra avere l'effetto opposto. Perché?


EDIT 2 : ho scoperto qualcosa di più.

__getitem__()e __contains()__sono contrassegnati come METH_COEXIST, perché sono dichiarati in PyDict_Type due volte.

Sono entrambi presenti, una volta, nello slot tp_methods, dove sono esplicitamente dichiarati come __getitem__()e __contains()__. Ma la documentazione ufficiale afferma che nontp_methods sono ereditati da sottoclassi.

Quindi una sottoclasse di dictnon chiama __getitem__(), ma chiama la sottopartita mp_subscript. In effetti, mp_subscriptè contenuto nello slot tp_as_mapping, che consente a una sottoclasse di ereditare i suoi sottogruppi.

Il problema è che sia __getitem__()e mp_subscriptutilizzano la stessa funzione, dict_subscript. È possibile che sia solo il modo in cui è stato ereditato a rallentarlo?


5
Non sono in grado di trovare la parte specifica del codice sorgente, ma credo che ci sia un percorso veloce nell'implementazione C che controlla se l'oggetto è un dicte, in tal caso, chiama l'implementazione C direttamente invece di cercare il __getitem__metodo da la classe dell'oggetto. Pertanto, il tuo codice esegue due ricerche dict, la prima per la chiave '__getitem__'nel dizionario dei Amembri della classe , quindi ci si può aspettare che sia circa due volte più lento. La picklespiegazione è probabilmente abbastanza simile.
kaya3

@ kaya3: Ma se è così, perché len(), ad esempio, non è 2x più lento ma ha la stessa velocità?
Marco Sulla

Non ne sono sicuro; Avrei pensato che lendovrebbe avere un percorso veloce per i tipi di sequenza incorporati. Non credo di essere in grado di dare una risposta adeguata alla tua domanda, ma è una buona risposta, quindi spero che qualcuno più esperto di Python internals di me risponderà.
kaya3,

Ho fatto qualche indagine e aggiornato la domanda.
Marco Sulla

1
...Oh. Ora lo vedo. L' __contains__implementazione esplicita sta bloccando la logica utilizzata per ereditare sq_contains.
user2357112 supporta Monica il

Risposte:


7

L'indicizzazione inè più lenta nelle dictsottoclassi a causa di una cattiva interazione tra dictun'ottimizzazione e le sottoclassi logiche utilizzate per ereditare gli slot C. Questo dovrebbe essere risolvibile, anche se non dalla tua parte.

L'implementazione di CPython ha due serie di hook per i sovraccarichi dell'operatore. Esistono metodi a livello di Python come __contains__e __getitem__, ma esiste anche un set separato di slot per i puntatori a funzioni C nel layout di memoria di un oggetto tipo. Di solito, il metodo Python sarà un wrapper per l'implementazione C, oppure lo slot C conterrà una funzione che cerca e chiama il metodo Python. È più efficiente per lo slot C implementare direttamente l'operazione, poiché lo slot C è quello a cui Python accede effettivamente.

I mapping scritti in C implementano gli slot C sq_containse mp_subscriptforniscono ine indicizzano. Normalmente, il livello __contains__e i __getitem__metodi di Python verrebbero generati automaticamente come wrapper attorno alle funzioni C, ma la dictclasse ha implementazioni esplicite di __contains__e __getitem__, poiché le implementazioni esplicite sono un po 'più veloci delle wrapper generate:

static PyMethodDef mapp_methods[] = {
    DICT___CONTAINS___METHODDEF
    {"__getitem__", (PyCFunction)(void(*)(void))dict_subscript,        METH_O | METH_COEXIST,
     getitem__doc__},
    ...

(In realtà, l' __getitem__implementazione esplicita ha la stessa funzione mp_subscriptdell'implementazione, solo con un diverso tipo di wrapper.)

Di solito, una sottoclasse erediterebbe le implementazioni dei suoi genitori di hook di livello C come sq_containse mp_subscript, e la sottoclasse sarebbe altrettanto veloce della superclasse. Tuttavia, la logica in update_one_slotcerca l'implementazione padre cercando di trovare i metodi wrapper generati attraverso una ricerca MRO.

dictnon ha generato wrapper per sq_containse mp_subscript, poiché fornisce espliciti __contains__e __getitem__implementazioni.

Invece di ereditare sq_containse mp_subscript, update_one_slotfinisce per dare la sottoclasse sq_containse mp_subscriptimplementazioni che esegue una ricerca di MRO __contains__e __getitem__e chiamare quelli. Questo è molto meno efficiente dell'ereditare direttamente gli slot C.

La correzione di questo richiederà modifiche update_one_slotall'implementazione.


A parte quanto ho descritto sopra, dict_subscriptcerca anche __missing__sottoclassi dict, quindi risolvere il problema dell'ereditarietà degli slot non renderà le sottoclassi completamente alla pari con dictse stessa per la velocità di ricerca, ma dovrebbe avvicinarle molto di più.


Per quanto riguarda il decapaggio, a dumpslato, l'implementazione del sottaceto ha un percorso rapido dedicato per i dicts, mentre la sottoclasse dict prende un percorso più circolare attraverso object.__reduce_ex__e save_reduce.

Sul loadslato, la differenza di tempo è principalmente __main__.Adovuta ai codici opzionali e alle ricerche extra per recuperare e creare un'istanza della classe, mentre i dadi hanno un codice operativo dedicato per il pickle per creare un nuovo dict. Se confrontiamo lo smontaggio per i sottaceti:

In [26]: pickletools.dis(pickle.dumps({0: 0, 1: 1, 2: 2, 3: 3, 4: 4}))                                                                                                                                                           
    0: \x80 PROTO      4
    2: \x95 FRAME      25
   11: }    EMPTY_DICT
   12: \x94 MEMOIZE    (as 0)
   13: (    MARK
   14: K        BININT1    0
   16: K        BININT1    0
   18: K        BININT1    1
   20: K        BININT1    1
   22: K        BININT1    2
   24: K        BININT1    2
   26: K        BININT1    3
   28: K        BININT1    3
   30: K        BININT1    4
   32: K        BININT1    4
   34: u        SETITEMS   (MARK at 13)
   35: .    STOP
highest protocol among opcodes = 4

In [27]: pickletools.dis(pickle.dumps(A({0: 0, 1: 1, 2: 2, 3: 3, 4: 4})))                                                                                                                                                        
    0: \x80 PROTO      4
    2: \x95 FRAME      43
   11: \x8c SHORT_BINUNICODE '__main__'
   21: \x94 MEMOIZE    (as 0)
   22: \x8c SHORT_BINUNICODE 'A'
   25: \x94 MEMOIZE    (as 1)
   26: \x93 STACK_GLOBAL
   27: \x94 MEMOIZE    (as 2)
   28: )    EMPTY_TUPLE
   29: \x81 NEWOBJ
   30: \x94 MEMOIZE    (as 3)
   31: (    MARK
   32: K        BININT1    0
   34: K        BININT1    0
   36: K        BININT1    1
   38: K        BININT1    1
   40: K        BININT1    2
   42: K        BININT1    2
   44: K        BININT1    3
   46: K        BININT1    3
   48: K        BININT1    4
   50: K        BININT1    4
   52: u        SETITEMS   (MARK at 31)
   53: .    STOP
highest protocol among opcodes = 4

vediamo che la differenza tra i due è che il secondo sottaceto ha bisogno di un sacco di codici operativi per cercare __main__.Ae creare un'istanza, mentre il primo sottaceto fa solo EMPTY_DICTper ottenere un dict vuoto. Successivamente, entrambi i sottaceti spingono le stesse chiavi e gli stessi valori nello stack dell'operando pickle ed eseguono SETITEMS.


Grazie mille! Hai idea del perché CPython utilizza questo strano metodo di ereditarietà? Voglio dire, non c'è un modo per dichiarare __contains__()e __getitem()in modo che possa essere ereditato da sottoclassi? Nella documentazione ufficiale di tp_methods, è scritto che methods are inherited through a different mechanism, quindi sembra possibile.
Marco Sulla

@MarcoSulla: __contains__e __getitem__ sono ereditati, ma il problema è che sq_containse mp_subscriptnon lo sono.
user2357112 supporta Monica il

Mh, beh .... aspetta un momento. Ho pensato che fosse il contrario. __contains__e __getitem__sono nello slot tp_methods, che per i documenti ufficiali non sono ereditati da sottoclassi. E come hai detto, update_one_slotnon usa sq_containse mp_subscript.
Marco Sulla

In parole povere, containse il resto non può essere semplicemente spostato in un altro slot, che è ereditato da sottoclassi?
Marco Sulla

@MarcoSulla: tp_methodsnon è ereditato, ma gli oggetti del metodo Python generati da esso sono ereditati nel senso che la ricerca MRO standard per l'accesso agli attributi li troverà.
user2357112 supporta Monica il
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.