[python] 파이썬에서 서브 클래 싱이 왜 그렇게 느려 집니까?

나는 확장하는 간단한 클래스에서 일하고 있었고 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 배 느리지 pickle5 배 느립니다.

어떻게 이럴 수있어? 다른 방법처럼 get(), __eq__()그리고 __init__(), 이상 반복 keys(), values()그리고 items()최대한 빨리이다 dict.


편집 : 파이썬 3.9의 소스 코드를 살펴 보았고 메소드가에 의해 구현 된 Objects/dictobject.c것 같습니다 . 그리고 서브 클래스가 구현할 수 있기 때문에 서브 클래스 느려지는 키가없는 경우에만 하고 그것이 존재하는지 확인하려고합니다. 그러나 벤치 마크는 기존 키를 사용했습니다.__getitem__()dict_subscript()dict_subscript()__missing__()

그러나 나는 무언가를 알아 냈습니다 : __getitem__()플래그로 정의되었습니다 METH_COEXIST. 또한 __contains__()2 배 느린 다른 방법은 동일한 플래그를 갖습니다. 로부터 공식 문서 :

기존 정의 대신 메소드가로드됩니다. METH_COEXIST가 없으면 기본값은 반복되는 정의를 건너 뛰는 것입니다. 슬롯 랩퍼는 메소드 테이블 전에로드되므로 sq_contains 슬롯의 존재는 예를 들어 contains () 라는 랩핑 된 메소드를 생성
하고 동일한 이름의 해당 PyCFunction을로드하지 못하게합니다. 플래그가 정의되면 PyCFunction이 랩퍼 오브젝트 대신로드되고 슬롯과 공존합니다. 이는 PyCFunction에 대한 호출이 랩퍼 오브젝트 호출보다 최적화되어 있기 때문에 유용합니다.

따라서 내가 올바르게 이해하면 이론상으로 METH_COEXIST속도를 높여야하지만 반대 효과가있는 것 같습니다. 왜?


편집 2 : 뭔가 더 발견했습니다.

__getitem__()__contains()__같은 플래그가 METH_COEXIST그들이 PyDict_Type에 선언되어 있기 때문에, 번.

둘 다 슬롯에 한 번 존재하며 tp_methods명시 적으로 __getitem__()and로 선언됩니다 __contains()__. 그러나 공식 문서는 말한다 tp_methods되어 있지 서브 클래스에 의해 상속.

따라서의 서브 클래스는 dict호출하지 않지만 __getitem__()서브 슬롯을 호출합니다 mp_subscript. 실제로 mp_subscriptslot에 포함되어 tp_as_mapping서브 클래스가 서브 슬롯을 상속 할 수 있도록합니다.

문제는 둘 것입니다 __getitem__()mp_subscript사용 같은 , 기능 dict_subscript. 그것이 상속받은 방식으로 만 느려지는 것이 가능합니까?



답변

최적화와 서브 클래스가 C 슬롯을 상속하는 데 사용하는 논리 간의 나쁜 상호 작용으로 인해 서브 클래스 in에서 인덱싱 및 속도가 느려집니다 . 이것은 끝이 아니라 고칠 수 있어야합니다.dictdict

CPython 구현에는 운영자 과부하를위한 두 개의 후크 세트가 있습니다. __contains__and와 같은 Python 레벨 메소드가 __getitem__있지만 유형 객체의 메모리 레이아웃에 C 함수 포인터를위한 별도의 슬롯 세트도 있습니다. 일반적으로 Python 메소드는 C 구현을 감싸는 래퍼이거나 C 슬롯에는 Python 메소드를 검색하고 호출하는 함수가 포함됩니다. C 슬롯은 Python이 실제로 액세스하는 것이므로 C 슬롯이 작업을 직접 구현하는 것이 더 효율적입니다.

C로 작성된 매핑은 C 슬롯을 구현 sq_contains하고 mp_subscript제공 in및 인덱싱. 일반적으로, 파이썬 수준 __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_containsmp_subscriptMRO가 검색을 수행 구현 __contains__하고 __getitem__그를 호출합니다. 이것은 C 슬롯을 직접 상속하는 것보다 훨씬 덜 효율적입니다.

이 문제를 해결하려면 update_one_slot구현을 변경해야합니다 .


이외에도 내가 위에서 설명한 것과, dict_subscript또한 조회 __missing__때문에 완전히 파에 서브 클래스를하지 않습니다 슬롯 상속 문제를 해결, DICT 하위 클래스에 대한 dict조회 속도를 자체하지만 많은 가까이를 얻어야한다.


산 세정에 관해서는, 상 dumps측면, 피클 구현이 갖는 전용 단축 경로 딕셔너리의 서브 클래스를 통해 더 원형 경로를 취하면서 dicts 대를 object.__reduce_ex__하고 save_reduce.

한편으로, loads시차는 대부분 __main__.A클래스 를 검색하고 인스턴스화하기위한 추가 opcode 및 조회와 관련 이 있으며, dicts에는 새로운 dict를 만들기위한 전용 pickle opcode가 있습니다. 피클의 분해를 비교하면 :

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인스턴스화하기 위해 많은 opcode가 필요하다는 것과 첫 번째 피클 EMPTY_DICT은 빈 dict를 얻는 것입니다. 그런 다음 두 피클은 동일한 키와 값을 피클 피연산자 스택에 넣고 실행 SETITEMS합니다.


답변