Чому підкласифікація в Python настільки сповільнює справи?


13

Я працював на простий клас , який простягається dict, і я зрозумів , що ключовий пошук і використання pickleє дуже повільно.

Я думав, що це проблема з моїм класом, тому я зробив кілька дрібницьких орієнтирів:

(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

Результати справді несподівані. Хоча пошук ключів у 2 рази повільніший, pickleна 5 разів повільніше.

Як це може бути? Інші методи, як get(), __eq__()і __init__()і ітерація keys(), values()і items()настільки ж швидкі dict.


EDIT : Я подивився вихідний код Python 3.9, і, Objects/dictobject.cздається, __getitem__()метод реалізований dict_subscript(). І dict_subscript()уповільнює підкласи лише в тому випадку, якщо ключ відсутній, оскільки підклас може реалізувати __missing__()і він намагається перевірити, чи існує. Але орієнтир був із наявним ключем.

Але я щось помітив: __getitem__()визначається прапором METH_COEXIST. А також __contains__(), інший метод, який у 2 рази повільніше, має той самий прапор. З офіційної документації :

Метод буде завантажений замість існуючих визначень. Без METH_COEXIST за замовчуванням є пропуск повторних визначень. Оскільки обгортки слотів завантажуються перед таблицею методів, існування, наприклад, слота sq_contains, генерує обернутий метод з назвою містить () і виключає завантаження відповідної PyCFunction з тим же ім'ям. Якщо визначений прапор, PyCFunction буде завантажений замість об'єкта обгортки і буде співіснувати з слотом. Це корисно, тому що виклики до PyCFunctions оптимізовані більше, ніж дзвінки об'єктного обгортання.

Тож якщо я правильно зрозумів, теоретично METH_COEXISTслід прискорити справи, але, здається, це має зворотний ефект. Чому?


EDIT 2 : Я виявив щось більше.

__getitem__()і __contains()__позначені як METH_COEXIST, тому що вони оголошуються в PyDict_Type два рази.

Вони обидва рази присутні в слоті tp_methods, де вони чітко оголошені як __getitem__()і __contains()__. Але в офіційній документації зазначено, що tp_methodsвони не успадковуються підкласами.

Таким чином, підклас dictне викликає __getitem__(), а викликає підмножину mp_subscript. Дійсно, mp_subscriptміститься в слоті tp_as_mapping, що дозволяє підкласу успадковувати його підпрограми.

Проблема полягає в тому, що обидва __getitem__()і mp_subscriptвикористовувати ту ж функцію, dict_subscript. Чи можливо, що це лише уповільнене сповіщення?


5
Я не в змозі знайти конкретну частину вихідного коду, але я вважаю, що в реалізації C є швидкий шлях, який перевіряє, чи є об'єкт a, dictі якщо так, викликає реалізацію C безпосередньо, а не шукати __getitem__метод з клас об’єкта. Таким чином, ваш код має два диктовки, перший - за ключем '__getitem__'у словнику членів класу A, тому можна очікувати, що він буде вдвічі повільнішим. pickleПояснення, ймовірно , дуже схожі.
kaya3

@ kaya3: Але якщо це так, чому len(), наприклад, не є в 2 рази повільнішим, але має однакову швидкість?
Марко Сулла

Я не впевнений у цьому; Я міг би подумати, що lenмає бути швидкий шлях для вбудованих типів послідовностей. Я не думаю, що я зможу дати належну відповідь на ваше запитання, але це хороший, тому, сподіваємось, хтось більш обізнаний про внутрішні питання Python, ніж я відповім на нього.
kaya3

Я провів деяке розслідування та оновив питання.
Марко Сулла

1
... ой. Я бачу це зараз. Явна __contains__реалізація блокує логіку, яка використовується для успадкування sq_contains.
user2357112 підтримує Моніку

Відповіді:


7

Індексація і inу dictпідкласах повільніша через погану взаємодію між dictоптимізацією та логічними підкласами, які використовують для успадкування слотів C. Це має бути виправданим, хоча і не з вашого кінця.

Реалізація CPython має два набори гаків для перевантаження оператора. Існують методи рівня Python на зразок __contains__та __getitem__, але також є окремий набір слотів для покажчиків функцій C в макеті пам'яті об'єкта типу. Зазвичай або метод Python буде обгорткою навколо реалізації C, або слот C буде містити функцію, яка здійснює пошук і виклик методу Python. Для слота C більш ефективно здійснювати операцію безпосередньо, оскільки слот C - це те, що Python насправді має доступ.

Відображення, написане на C, реалізує слоти C sq_containsі mp_subscriptзабезпечує inта індексує. Зазвичай, Python рівня __contains__і __getitem__методи будуть автоматично генеруватися як обгортки навколо функцій C, але dictклас має явні реалізації з __contains__і __getitem__, так як явні реалізацій трохи швидше , ніж згенеровані обгортки:

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

(Насправді явна __getitem__реалізація - це та сама функція, що й mp_subscriptреалізація, лише з різним видом обгортки.)

Зазвичай підклас успадковує реалізацію свого батька гачків C-рівень , як sq_containsі mp_subscript, і підклас буде настільки ж швидко , як суперклас. Однак логіка update_one_slotшукає батьківську реалізацію, намагаючись знайти згенеровані методи обгортки за допомогою пошуку MRO.

dictНЕ має згенеровані обгорток для sq_containsі mp_subscript, оскільки вона забезпечує явне __contains__і __getitem__реалізації.

Замість того , щоб успадкувати sq_containsі mp_subscript, в update_one_slotкінцевому підсумку дає підклас sq_containsі mp_subscriptреалізації , які виконують пошук MRO для __contains__і __getitem__і назвати їх. Це набагато менш ефективно, ніж успадкування слотів C безпосередньо.

Для виправлення цього буде потрібно зміни в update_one_slotреалізації.


Крім того, що я описав вище, dict_subscriptтакож розглядаються __missing__підкласи dict, тому виправлення проблеми успадкування слотів не зробить підкласи повністю нарівні з dictсобою за швидкістю пошуку, але це повинно наблизити їх.


Що стосується маринування, на dumpsбоці реалізація соління має виділений швидкий шлях для диктів, тоді як підклас дикту займає більш обхідний шлях через object.__reduce_ex__і save_reduce.

З іншого loadsбоку, різниця в часі здебільшого полягає в додаткових опкодах і пошуку, щоб отримати та інстанціювати __main__.Aклас, у той час як у диктів є спеціалізований кодовий вибір для створення нового дикту. Якщо порівняти розбирання на соління:

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

ми бачимо, що різниця між ними полягає в тому, що другий солінь потребує цілого ряду опкодів, щоб шукати його __main__.Aта інстанціювати, тоді як перший маринований EMPTY_DICTнабір робить просто порожній вислів. Після цього обидва соління натискають однакові клавіші та значення на стек операнду соління та запускаються SETITEMS.


Дуже дякую! Чи маєте ви ідею, чому CPython використовує цей дивний спосіб успадкування? Я маю на увазі, чи не існує способу декларування __contains__()та __getitem()таким чином, який може бути успадкований підкласами? В офіційній документації Росії tp_methodsнаписано, що methods are inherited through a different mechanismтак, здається, можливо.
Марко Сулла,

@MarcoSulla: __contains__і __getitem__ будуть успадковані, але проблема в тому , що sq_containsі mp_subscriptнемає.
user2357112 підтримує Моніку

М-ну, ну .... зачекай хвилинку. Я думав, що це навпаки. __contains__і __getitem__є в слоті tp_methods, що для офіційних документів не успадковується підкласами. І як ви сказали, update_one_slotне використовує sq_containsі mp_subscript.
Марко Сулла,

Якщо погано containsсказати , а решту неможливо просто перемістити в інший слот, який успадковується підкласами?
Марко Сулла,

@MarcoSulla: tp_methodsне успадковується, але об’єкти методу Python, згенеровані з нього, успадковуються в тому сенсі, що стандартний MRO пошук доступу до атрибутів знайде їх.
user2357112 підтримує Моніку
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.