Mengapa subclassing di Python memperlambat banyak hal?

13

Saya bekerja pada sebuah kelas sederhana yang meluas dict, dan saya menyadari bahwa kunci pencarian dan penggunaan pickleyang sangat lambat.

Saya pikir itu masalah dengan kelas saya, jadi saya melakukan beberapa tolok ukur sepele:

(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

Hasilnya sungguh mengejutkan. Sementara pencarian kunci 2x lebih lambat, pickleadalah 5x lebih lambat.

Bagaimana ini bisa terjadi? Metode lain, seperti get(), __eq__()dan __init__(), dan iterasi berakhir keys(), values()dan items()secepat dict.


EDIT : Saya melihat kode sumber Python 3.9, dan Objects/dictobject.ctampaknya __getitem__()metode ini diimplementasikan oleh dict_subscript(). Dan dict_subscript()memperlambat subclass hanya jika kuncinya hilang, karena subclass dapat diimplementasikan __missing__()dan mencoba untuk melihat apakah ada. Namun tolok ukurnya adalah dengan kunci yang ada.

Tapi saya perhatikan sesuatu: __getitem__()didefinisikan dengan bendera METH_COEXIST. Dan juga __contains__(), metode lain yaitu 2x lebih lambat, memiliki flag yang sama. Dari dokumentasi resmi :

Metode ini akan dimuat di tempat definisi yang ada. Tanpa METH_COEXIST, standarnya adalah melompati definisi yang berulang. Karena pembungkus slot dimuat sebelum tabel metode, keberadaan slot sq_contains, misalnya, akan menghasilkan metode terbungkus bernama berisi () dan mencegah pemuatan fungsi PyCF yang sesuai dengan nama yang sama. Dengan flag yang ditentukan, PyCFunction akan dimuat di tempat objek wrapper dan akan hidup berdampingan dengan slot. Ini membantu karena panggilan ke PyCFunctions dioptimalkan lebih dari panggilan objek wrapper.

Jadi jika saya mengerti dengan benar, secara teori METH_COEXISTharus mempercepat, tetapi tampaknya memiliki efek sebaliknya. Mengapa?


EDIT 2 : Saya menemukan sesuatu yang lebih.

__getitem__()dan __contains()__ditandai sebagai METH_COEXIST, karena dideklarasikan dalam PyDict_Type dua kali.

Keduanya hadir, satu kali, di slot tp_methods, di mana mereka secara eksplisit dinyatakan sebagai __getitem__()dan __contains()__. Tetapi dokumentasi resmi mengatakan bahwa tp_methodsitu tidak diwarisi oleh subclass.

Jadi subkelas dicttidak memanggil __getitem__(), tetapi memanggil subslot mp_subscript. Memang, mp_subscriptterkandung dalam slot tp_as_mapping, yang memungkinkan subclass untuk mewarisi sublotnya.

Masalahnya adalah bahwa keduanya __getitem__()dan mp_subscriptmenggunakan fungsi yang samadict_subscript ,. Apakah mungkin hanya warisan yang memperlambatnya?

Marco Sulla
sumber
5
Saya tidak dapat menemukan bagian spesifik dari kode sumber, tetapi saya percaya ada jalur cepat dalam implementasi C yang memeriksa apakah objeknya adalah dictdan jika demikian, panggil implementasi C secara langsung alih-alih mencari __getitem__metode dari kelas objek. Karenanya kode Anda melakukan dua pencarian dict, yang pertama untuk kunci '__getitem__'dalam kamus anggota kelas A, sehingga dapat diperkirakan sekitar dua kali lebih lambat. The picklepenjelasan mungkin cukup mirip.
kaya3
@ kaya3: Tetapi jika demikian, mengapa len(), misalnya, tidak lebih lambat 2x tetapi memiliki kecepatan yang sama?
Marco Sulla
Saya tidak yakin tentang itu; Saya akan berpikir lenharus memiliki jalur cepat untuk tipe urutan bawaan. Saya tidak berpikir saya bisa memberikan jawaban yang tepat untuk pertanyaan Anda, tetapi itu adalah jawaban yang bagus, jadi semoga seseorang yang lebih berpengetahuan tentang internal Python daripada saya akan menjawabnya.
kaya3
Saya telah melakukan beberapa penyelidikan dan memperbarui pertanyaan.
Marco Sulla
1
... oh Saya melihatnya sekarang. __contains__Implementasi eksplisit memblokir logika yang digunakan untuk mewarisi sq_contains.
user2357112 mendukung Monica

Jawaban:

7

Pengindeksan dan inlebih lambat dalam dictsubclass karena interaksi yang buruk antara dictoptimasi dan subclass logika digunakan untuk mewarisi slot C. Ini harus diperbaiki, meskipun bukan dari ujung Anda.

Implementasi CPython memiliki dua set kait untuk kelebihan operator. Ada metode tingkat Python seperti __contains__dan __getitem__, tetapi ada juga satu set slot yang terpisah untuk pointer fungsi C dalam tata letak memori objek tipe. Biasanya, metode Python akan menjadi pembungkus implementasi C, atau slot C akan berisi fungsi yang mencari dan memanggil metode Python. Ini lebih efisien untuk slot C untuk mengimplementasikan operasi secara langsung, karena slot C adalah apa yang sebenarnya diakses oleh Python.

Pemetaan yang ditulis dalam C mengimplementasikan slot C sq_containsdan mp_subscriptuntuk menyediakan indan mengindeks. Biasanya, Python tingkat __contains__dan __getitem__metode akan secara otomatis dihasilkan sebagai bungkus sekitar fungsi C, tapi dictkelas memiliki implementasi eksplisit dari __contains__dan __getitem__, karena implementasi eksplisit sedikit lebih cepat dari pembungkus yang dihasilkan:

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

(Sebenarnya, __getitem__implementasi eksplisit adalah fungsi yang sama dengan mp_subscriptimplementasi, hanya dengan jenis pembungkus yang berbeda.)

Biasanya, sebuah subclass akan mewarisi implementasi induknya dari kait tingkat-C seperti sq_containsdan mp_subscript, dan subclass itu akan sama cepatnya dengan superclass. Namun, logika dalam update_one_slotmencari implementasi induk dengan mencoba menemukan metode pembungkus yang dihasilkan melalui pencarian MRO.

dicttidak memiliki pembungkus dihasilkan untuk sq_containsdan mp_subscript, karena memberikan eksplisit __contains__dan __getitem__implementasi.

Alih-alih mewarisi sq_containsdan mp_subscript, update_one_slotakhirnya memberikan subclass sq_containsdan mp_subscriptimplementasi yang melakukan pencarian MRO untuk __contains__dan __getitem__dan menyebutnya. Ini jauh lebih efisien daripada mewarisi slot C secara langsung.

Memperbaiki ini akan membutuhkan perubahan pada update_one_slotimplementasi.


Selain dari apa yang saya jelaskan di atas, dict_subscriptjuga mencari __missing__subclass dict, sehingga memperbaiki masalah pewarisan slot tidak akan membuat subclass sepenuhnya setara dengan dictdirinya sendiri untuk kecepatan pencarian, tetapi harus membuat mereka lebih dekat.


Adapun acar, di dumpssamping, implementasi acar memiliki jalur cepat khusus untuk dicts, sedangkan subclass dict mengambil jalur yang lebih bundaran melalui object.__reduce_ex__dan save_reduce.

Di loadssamping, perbedaan waktu sebagian besar hanya dari opcodes tambahan dan pencarian untuk mengambil dan membuat instance __main__.Akelas, sementara dikt memiliki opcode acar khusus untuk membuat dikt baru. Jika kita membandingkan pembongkaran untuk acar:

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

kita melihat bahwa perbedaan antara keduanya adalah bahwa acar kedua membutuhkan sejumlah besar opcodes untuk mencari __main__.Adan membuat instance, sedangkan acar pertama hanya EMPTY_DICTuntuk mendapatkan dict kosong. Setelah itu, kedua acar mendorong kunci dan nilai yang sama ke tumpukan acar dan berlari SETITEMS.

user2357112 mendukung Monica
sumber
Terima kasih banyak! Pernahkah Anda tahu mengapa CPython menggunakan metode pewarisan aneh ini? Maksud saya, apakah tidak ada cara untuk mendeklarasikan __contains__()dan __getitem()sedemikian rupa sehingga dapat diwarisi oleh subclass? Dalam dokumentasi resmi tp_methods, tertulis itu methods are inherited through a different mechanism, jadi sepertinya mungkin.
Marco Sulla
@MarcoSulla: __contains__dan __getitem__ yang diwariskan, tetapi masalahnya adalah bahwa sq_containsdan mp_subscripttidak.
user2357112 mendukung Monica
Mh, wah .... tunggu sebentar. Saya pikir itu sebaliknya. __contains__dan __getitem__berada dalam slot tp_methods, bahwa untuk dokumen resmi tidak diwarisi oleh subclass. Dan seperti yang Anda katakan, update_one_slottidak menggunakan sq_containsdan mp_subscript.
Marco Sulla
Dengan kata-kata yang buruk, containsdan sisanya tidak bisa hanya dipindahkan di slot lain, yang diwarisi oleh subclass?
Marco Sulla
@ MarscoSulla: tp_methodstidak diwariskan, tetapi objek metode Python yang dihasilkan darinya diwarisi dalam arti bahwa pencarian MRO standar untuk akses atribut akan menemukannya.
user2357112 mendukung Monica