Tại sao phân lớp trong Python làm mọi thứ chậm lại rất nhiều?


13

Tôi đã làm việc trên một lớp đơn giản mà kéo dài dict, và tôi nhận ra rằng chìa khóa tra cứu và sử dụng picklerất chậm.

Tôi nghĩ rằng đó là một vấn đề với lớp học của tôi, vì vậy tôi đã làm một số điểm chuẩn tầm thường:

(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

Kết quả thực sự là một bất ngờ. Trong khi tra cứu quan trọng là 2x chậm hơn, pickle5x chậm hơn.

Làm sao có thể? Các phương thức khác, như get(), __eq__()__init__(), lặp đi lặp lại keys(), values()items()nhanh như dict.


EDIT : Tôi đã xem mã nguồn của Python 3.9 và Objects/dictobject.cdường như __getitem__()phương thức này được thực hiện bởi dict_subscript(). Và dict_subscript()làm chậm các lớp con chỉ khi khóa bị thiếu, vì lớp con có thể thực hiện __missing__()và nó cố gắng xem nó có tồn tại không. Nhưng điểm chuẩn là với một khóa hiện có.

Nhưng tôi nhận thấy một điều: __getitem__()được định nghĩa với cờ METH_COEXIST. Ngoài ra __contains__(), phương pháp khác chậm hơn gấp 2 lần, có cùng một cờ. Từ tài liệu chính thức :

Phương pháp sẽ được tải thay cho các định nghĩa hiện có. Không có METH_COEXIST, mặc định là bỏ qua các định nghĩa lặp lại. Do các trình bao bọc vị trí được tải trước bảng phương thức, ví dụ, sự tồn tại của vị trí sq_contains sẽ tạo ra một phương thức được bao bọc có tên chứa () và loại trừ việc tải một PyCFunction tương ứng có cùng tên. Với cờ được xác định, PyCFunction sẽ được tải thay cho đối tượng trình bao bọc và sẽ cùng tồn tại với vị trí. Điều này rất hữu ích vì các lệnh gọi đến PyCFifts được tối ưu hóa nhiều hơn các lệnh gọi đối tượng trình bao bọc.

Vì vậy, nếu tôi hiểu chính xác, trên lý thuyết METH_COEXISTsẽ tăng tốc mọi thứ, nhưng dường như nó có tác dụng ngược lại. Tại sao?


EDIT 2 : Tôi phát hiện ra một cái gì đó nhiều hơn.

__getitem__()__contains()__được gắn cờ là METH_COEXIST, bởi vì chúng được khai báo trong PyDict_Type hai lần.

Cả hai đều có mặt, một lần, trong khe tp_methods, nơi chúng được tuyên bố rõ ràng là __getitem__()__contains()__. Tuy nhiên, tài liệu chính thức nói rằng tp_methodsđang không được thừa kế bởi các lớp con.

Vì vậy, một lớp con dictkhông gọi __getitem__(), nhưng gọi lớp con mp_subscript. Thật vậy, mp_subscriptđược chứa trong khe tp_as_mapping, cho phép một lớp con kế thừa các tập con của nó.

Vấn đề là cả hai __getitem__()mp_subscriptsử dụng cùng một chức năng , dict_subscript. Có thể đó chỉ là cách nó được thừa kế làm nó chậm lại?


5
Tôi không thể tìm thấy phần cụ thể của mã nguồn, nhưng tôi tin rằng có một đường dẫn nhanh trong triển khai C để kiểm tra xem đối tượng có phải là một dictvà nếu vậy, hãy gọi trực tiếp triển khai C thay vì tìm kiếm __getitem__phương thức từ lớp của đối tượng. Do đó, mã của bạn thực hiện hai tra cứu chính tả, mã đầu tiên cho khóa '__getitem__'trong từ điển của Acác thành viên của lớp , do đó, nó có thể được dự kiến ​​sẽ chậm hơn khoảng hai lần. Lời picklegiải thích có lẽ khá giống nhau.
kaya3

@ kaya3: Nhưng nếu nó là như vậy, tại sao len(), ví dụ, không phải là chậm hơn 2 lần mà có cùng tốc độ?
Marco Sulla

Tôi không chắc về điều đó; Tôi đã nghĩ lennên có một đường dẫn nhanh cho các loại trình tự tích hợp. Tôi không nghĩ rằng tôi có thể đưa ra một câu trả lời thích hợp cho câu hỏi của bạn, nhưng đó là một câu hỏi hay, vì vậy hy vọng ai đó hiểu biết nhiều hơn về nội bộ Python hơn tôi sẽ trả lời nó.
kaya3

Tôi đã thực hiện một số điều tra và cập nhật câu hỏi.
Marco Sulla

1
...Oh. Tôi thấy nó bây giờ. Việc __contains__thực hiện rõ ràng là chặn logic được sử dụng để kế thừa sq_contains.
user2357112 hỗ trợ Monica

Câu trả lời:


7

Lập chỉ mục và inchậm hơn trong các dictlớp con do tương tác xấu giữa dicttối ưu hóa và các lớp con logic sử dụng để kế thừa các khe C. Điều này nên được sửa chữa, mặc dù không phải từ cuối của bạn.

Việc triển khai CPython có hai bộ hook cho quá tải toán tử. Có các phương thức mức Python như __contains____getitem__, nhưng cũng có một bộ vị trí riêng cho các con trỏ hàm C trong bố cục bộ nhớ của một đối tượng loại. Thông thường, phương thức Python sẽ là một trình bao bọc xung quanh việc thực hiện C hoặc vị trí C sẽ chứa một hàm tìm kiếm và gọi phương thức Python. Nó hiệu quả hơn cho khe C để thực hiện thao tác trực tiếp, vì khe C là thứ mà Python thực sự truy cập.

Các ánh xạ được viết bằng C thực hiện các khe C sq_containsmp_subscriptđể cung cấp invà lập chỉ mục. Thông thường, các Python cấp __contains____getitem__phương pháp sẽ được tự động tạo ra như hàm bao quanh các chức năng C, nhưng các dictlớp có triển khai rõ ràng của __contains____getitem__, bởi vì hiện thực rõ ràng là một chút nhanh hơn so với giấy gói được tạo ra:

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

(Trên thực tế, việc __getitem__triển khai rõ ràng là cùng chức năng với việc mp_subscripttriển khai, chỉ với một loại trình bao bọc khác.)

Thông thường, một lớp con sẽ kế thừa các triển khai móc cấp độ C của cha mẹ nó sq_containsmp_subscript, và lớp con sẽ nhanh như lớp cha. Tuy nhiên, logic update_one_slottìm kiếm việc thực hiện cha mẹ bằng cách cố gắng tìm các phương thức trình bao bọc được tạo thông qua tìm kiếm MRO.

dictkhông giấy gói tạo ra cho sq_containsmp_subscript, vì nó cung cấp rõ ràng __contains____getitem__hiện thực.

Thay vì kế thừa sq_containsmp_subscript, update_one_slotđầu lên cho các lớp con sq_containsmp_subscripthiện thực mà thực hiện một MRO tìm kiếm __contains____getitem__và gọi những người. Điều này kém hiệu quả hơn nhiều so với việc kế thừa các khe C trực tiếp.

Sửa lỗi này sẽ yêu cầu thay đổi để update_one_slotthực hiện.


Ngoài những gì tôi đã mô tả ở trên, dict_subscriptcũng tìm kiếm __missing__các lớp con chính tả, vì vậy việc khắc phục vấn đề thừa kế vị trí sẽ không làm cho các lớp con hoàn toàn ngang bằng với dicttốc độ tra cứu, nhưng nó sẽ giúp chúng tiến gần hơn rất nhiều.


Đối với dưa chua, ở dumpsbên cạnh, việc thực hiện dưa chua có một đường dẫn nhanh dành riêng cho các dicts, trong khi lớp con dict có một đường vòng quanh hơn object.__reduce_ex__save_reduce.

Về loadsphía, sự khác biệt về thời gian chủ yếu chỉ là từ các opcodes bổ sung và tra cứu để truy xuất và khởi tạo __main__.Alớp, trong khi các dicts có một opcode dưa chua chuyên dụng để tạo ra một lệnh mới. Nếu chúng ta so sánh việc tháo gỡ cho dưa chua:

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

chúng ta thấy rằng sự khác biệt giữa hai loại này là dưa chua thứ hai cần cả đống opcodes để tra cứu __main__.Avà khởi tạo nó, trong khi dưa chua đầu tiên chỉ làm EMPTY_DICTđược một lệnh trống. Sau đó, cả hai dưa chua đều đẩy các khóa và giá trị giống nhau lên ngăn xếp toán hạng dưa và chạy SETITEMS.


Cảm ơn bạn rất nhiều! Bạn có biết tại sao CPython sử dụng phương pháp kế thừa kỳ lạ này không? Ý tôi là, không có cách nào để khai báo __contains__()__getitem()theo cách có thể được kế thừa bởi các lớp con? Trong tài liệu chính thức tp_methods, nó được viết methods are inherited through a different mechanismnhư vậy, nên dường như có thể.
Marco Sulla

@MarcoSulla: __contains____getitem__ được kế thừa, nhưng vấn đề là có sq_containsmp_subscriptkhông.
user2357112 hỗ trợ Monica

Mh, tốt .... chờ một lát. Tôi nghĩ đó là điều ngược lại. __contains____getitem__nằm trong vị trí tp_methodsđó, đối với các tài liệu chính thức không được kế thừa bởi các lớp con. Và như bạn đã nói, update_one_slotkhông sử dụng sq_containsmp_subscript.
Marco Sulla

Nói cách khác, containsvà phần còn lại không thể được di chuyển đơn giản trong một vị trí khác, được kế thừa bởi các lớp con?
Marco Sulla

@MarcoSulla: tp_methodskhông được kế thừa, nhưng các đối tượng phương thức Python được tạo từ nó được kế thừa theo nghĩa là tìm kiếm MRO tiêu chuẩn để truy cập thuộc tính sẽ tìm thấy chúng.
user2357112 hỗ trợ Monica
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.