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


編集:私はPython 3.9のソースコードを調べましたObjects/dictobject.cが、その__getitem__()方法はによって実装されているようdict_subscript()です。またdict_subscript()、サブクラスは実装でき__missing__()、存在するかどうかを確認しようとするため、キーがない場合にのみサブクラスの速度を低下させます。しかし、ベンチマークは既存のキーでした。

しかし、私は何かに気づきました:__getitem__()フラグで定義されていますMETH_COEXIST。また__contains__()、2倍遅いもう1つの方法には同じフラグがあります。公式ドキュメントから:

メソッドは、既存の定義の代わりにロードされます。METH_COEXISTがない場合、デフォルトでは、繰り返される定義をスキップします。スロットラッパーはメソッドテーブルの前に読み込まれるため、たとえばsq_containsスロットが存在すると、contains()という名前のラップされたメソッドが生成さ 、同じ名前の対応するPyCFunctionが読み込まれなくなります。フラグが定義されると、ラッパーオブジェクトの代わりにPyCFunctionが読み込まれ、スロットと共存します。PyCFunctionsの呼び出しは、ラッパーオブジェクトの呼び出しよりも最適化されているため、これは役立ちます。

したがって、私が正しく理解していれば、理論的にMETH_COEXISTは速度が上がるはずですが、逆の効果があるようです。どうして?


編集2:私はもっと何かを発見しました。

__getitem__()そして、__contains()__としてフラグ付けされているMETH_COEXIST彼らはPyDict_Typeで宣言されているので、2回。

これらは両方ともスロットに1回だけ存在し、tp_methodsととして明示的に宣言され__getitem__()てい__contains()__ます。しかし、公式のドキュメントはそれtp_methodsがサブクラスによって継承されないことを述べています。

したがって、のサブクラスはをdict呼び出さず__getitem__()、サブスロットを呼び出しますmp_subscript。実際、mp_subscripttp_as_mapping、サブクラスがそのサブスロットを継承できるようにするslot に含まれています。

問題は、__getitem__()mp_subscript同じ関数を使用していることdict_subscriptです。それが遅くなるのはそれが継承された方法だけであることは可能ですか?


5
ソースコードの特定の部分を見つけることができませんが、C実装にはオブジェクトがかどうかをチェックする高速パスがあると思います。そうである場合はdict__getitem__メソッドをルックアップする代わりにC実装を直接呼び出しますオブジェクトのクラス。したがって、コードは2つのdictルックアップを実行します。1つ'__getitem__'はクラスAのメンバーのディクショナリのキーに対するものであるため、約2倍の速度が期待できます。pickle説明は、おそらく非常に似ています。
kaya3

@ kaya3:もしそうなら、なぜlen()、例えば、2倍遅くはないが同じ速度なのですか?
Marco Sulla

それについてはよくわかりません; len組み込みシーケンスタイプの高速パスが必要だと思っていました。私はあなたの質問に適切な答えを出すことができるとは思いませんが、それは良い答えなので、うまくいけば、私よりもPythonの内部について詳しい人が答えるでしょう。
kaya3

調査を行い、質問を更新しました。
Marco Sulla

1
...ああ。今見えます。明示的な__contains__実装は、継承に使用されるロジックをブロックしていsq_containsます。
user2357112は23:27にMonica

回答:


7

最適化とサブクラスがCスロットを継承するために使用するロジックとの間の相互作用が悪いためindictサブクラスでのインデックス作成と低速dict化。これは修正できるはずですが、あなたの側からではありません。

CPythonの実装には、オペレーターのオーバーロード用に2セットのフックがあります。__contains__andのようなPythonレベルのメソッド__getitem__がありますが、型オブジェクトのメモリレイアウトには、C関数ポインタ用のスロットの別のセットもあります。通常、PythonメソッドはC実装のラッパーになるか、CスロットにはPythonメソッドを検索して呼び出す関数が含まれます。CスロットはPythonが実際にアクセスするものであるため、Cスロットが直接操作を実装する方が効率的です。

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_containsmp_subscript、サブクラスは、同じように速いスーパーなどでしょう。ただし、のロジックはupdate_one_slot、MRO検索で生成されたラッパーメソッドを見つけようとして、親の実装を探します。

dictいないいるためにラッパーを生成sq_containsし、mp_subscriptそれが明示的に提供しているため、__contains__および__getitem__実装を。

代わりに継承するsq_containsmp_subscriptupdate_one_slotサブクラス与え終わるsq_containsmp_subscriptMROが検索を実行実装を__contains__して__getitem__、それらを呼び出します。これは、Cスロットを直接継承するよりもはるかに効率的ではありません。

これを修正するには、update_one_slot実装を変更する必要があります。


上記で説明したものの他に、dictサブクラスdict_subscriptも検索する__missing__ため、スロットの継承の問題を修正しても、サブクラスはdictそれ自体と完全に同じにはなりませんが、検索速度はかなり近くなります。


酸洗として、上dumps側面、ピクルス実装が有する専用の高速パス辞書サブクラスがを通じてより迂回経路をとりながら、dictsために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

2つの違いは、2番目のピクルがルックアップ__main__.Aしてインスタンス化するためにオペコードの束全体を必要とするのに対し、最初のピクルEMPTY_DICTは空の辞書を取得するだけであることがわかります。その後、両方のピクルが同じキーと値をピクルオペランドスタックにプッシュして実行しSETITEMSます。


大いに感謝する!CPythonがこの奇妙な継承メソッドを使用する理由を何か知っていますか?私が意味する、宣言するための方法はありません__contains__()し、__getitem()サブクラスによって継承できるように?の公式ドキュメントにはtp_methods、と書かれているmethods are inherited through a different mechanismので可能だと思われます。
Marco Sulla

@MarcoSulla:__contains____getitem__ されて継承されたが、問題があることであるsq_containsmp_subscriptされません。
user2357112はモニカ

えっと、まぁ……ちょっと待って。逆だと思いました。__contains____getitem__スロットtp_methodsにあります。公式ドキュメントの場合、サブクラスによって継承されません。そして、あなたが言ったように、とをupdate_one_slot使用しません。sq_containsmp_subscript
Marco Sulla

貧しい言葉で、containsそして残りは単にサブクラスによって継承される別のスロットに移動することはできませんか?
Marco Sulla

@MarcoSulla:tp_methodsは継承されませんが、それから生成されたPythonメソッドオブジェクトは、属性アクセスのための標準のMRO検索がそれらを見つけるという意味で継承されます。
user2357112は
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.