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


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 배 느리지 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. 그것이 상속받은 방식으로 만 느려지는 것이 가능합니까?


5
나는 소스 코드의 특정 부분을 찾을 수 없습니다,하지만 난이 객체가 인 경우 어떤 검사 C 구현의 빠른 경로가 생각 dict그렇다면 C 구현을 호출하는 대신 직접의 찾는 __getitem__에서 방법을 객체의 클래스 따라서 코드 '__getitem__'는 클래스 A멤버 사전의 키 에 대한 첫 번째 dict 조회 를 두 번 수행하므로 약 두 배 느릴 것으로 예상됩니다. pickle설명은 아마 매우 유사하다.
kaya3

@ kaya3 : 그러나 그렇다면, len()예를 들어 왜 2 배는 느리지 않지만 같은 속도를 가지는가 ?
마르코 술라

나는 그것에 대해 확실하지 않다; len내장 시퀀스 유형에 대한 빠른 경로 가 있어야한다고 생각했을 것 입니다. 나는 당신의 질문에 대한 올바른 대답을 줄 수 없다고 생각하지만, 좋은 질문이므로 파이썬 내부에 ​​대해 더 많은 지식을 가진 사람이 대답 할 것입니다.
kaya3

조사를 수행하고 질문을 업데이트했습니다.
마르코 술라

1
...오. 나는 지금 본다. 명시 적 __contains__구현이 상속에 사용되는 논리를 차단하고 sq_contains있습니다.
user2357112는

답변:


7

최적화와 서브 클래스가 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합니다.


정말 고마워! CPython이이 이상한 상속 방법을 사용하는 이유를 알고 있습니까? 나는 선언하는 방법이 아닌, 의미 __contains__()__getitem()서브 클래스에 의해 상속 될 수있는 방법으로? 의 공식 문서에는 tp_methods이라고 쓰여 methods are inherited through a different mechanism있으므로 가능합니다.
마르코 술라

@MarcoSulla은 : __contains____getitem__ 있습니다 상속,하지만 문제가 있다는 것입니다 sq_containsmp_subscript되지 않습니다.
user2357112는

음 .. 잠깐만 나는 그것이 반대라고 생각했다. __contains__그리고 공식 문서의 경우 서브 클래스에 의해 상속되지 않는 __getitem__슬롯 tp_methods에 있습니다. 그리고 당신이 말했듯 update_one_slotsq_contains및을 사용하지 않습니다 mp_subscript.
마르코 술라

나쁜 말로, contains나머지는 단순히 다른 슬롯으로 옮길 수 없으며 하위 클래스에 의해 상속됩니까?
마르코 술라

@MarcoSulla : tp_methods는 상속되지 않지만, 그것으로부터 생성 된 Python 메소드 객체는 속성 액세스에 대한 표준 MRO 검색에서 찾을 것이라는 의미에서 상속됩니다.
user2357112는
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.