ทำไมการทำคลาสย่อยใน 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

ผลลัพธ์เป็นสิ่งที่น่าประหลาดใจจริงๆ ในขณะที่การค้นหาที่สำคัญคือ 2x ช้าpickleเป็น5xช้าลง

สิ่งนี้จะเป็นอย่างไร วิธีการอื่น ๆ เช่นget(), __eq__()และ__init__()และย้ำกว่าkeys(), values()และเป็นเร็วที่สุดเท่าที่items()dict


แก้ไข : ฉันได้ดูรหัสที่มาของงูใหญ่ 3.9 และในObjects/dictobject.cดูเหมือนว่าวิธีการที่ดำเนินการโดย__getitem__() dict_subscript()และdict_subscript()ทำให้คลาสย่อยช้าลงหากคีย์หายไปเนื่องจากคลาสย่อยสามารถใช้งานได้__missing__()และจะพยายามดูว่ามีอยู่หรือไม่ แต่เกณฑ์มาตรฐานก็คือรหัสที่มีอยู่

แต่ผมสังเกตเห็นบางสิ่งบางอย่างที่ถูกกำหนดให้กับธง__getitem__() METH_COEXISTและ__contains__()อีกวิธีที่ช้ากว่า 2x นั้นมีค่าสถานะเดียวกัน จากเอกสารอย่างเป็นทางการ :

วิธีการจะถูกโหลดแทนคำจำกัดความที่มีอยู่ หากไม่มี METH_COEXIST ค่าดีฟอลต์คือข้ามคำจำกัดความซ้ำ ตั้งแต่ห่อสล็อตโหลดก่อนตารางวิธีการดำรงอยู่ของช่องเสียบ sq_contains เช่นจะสร้างวิธีการห่อชื่อ มี () และดักคอโหลด PyCFunction ที่เกี่ยวข้องที่มีชื่อเดียวกัน ด้วยการกำหนดค่าสถานะ PyCFunction จะถูกโหลดแทนที่วัตถุ wrapper และจะอยู่ร่วมกับสล็อต สิ่งนี้มีประโยชน์เนื่องจากการเรียก PyCFunctions ได้รับการปรับให้เหมาะสมมากกว่าการเรียกอ็อบเจ็กต์ wrapper

ดังนั้นหากฉันเข้าใจถูกต้องตามทฤษฎีแล้วMETH_COEXISTควรเร่งสิ่งต่าง ๆ แต่ดูเหมือนว่าจะมีผลตรงกันข้าม ทำไม?


แก้ไข 2 : ฉันค้นพบบางสิ่งเพิ่มเติม

__getitem__()และ__contains()__ถูกตั้งค่าสถานะเป็นMETH_COEXISTเนื่องจากมีการประกาศใน PyDict_Type สองครั้ง

พวกเขาทั้งสองปัจจุบันครั้งหนึ่งในช่องtp_methodsที่พวกเขามีการประกาศอย่างชัดเจนว่าและ__getitem__() __contains()__แต่เอกสารที่เป็นทางการกล่าวว่าtp_methodsจะไม่ได้รับการถ่ายทอดจาก subclasses

ดังนั้น subclass ของdictไม่เรียก__getitem__()แต่เรียก mp_subscriptsubslot แท้จริงแล้วmp_subscriptมีอยู่ในสล็อตtp_as_mappingที่อนุญาตให้คลาสย่อยรับช่วงย่อย

ปัญหาคือทั้งสอง__getitem__()และmp_subscriptใช้ฟังก์ชั่นเดียวกัน , dict_subscript. เป็นไปได้ไหมว่ามันเป็นเพียงวิธีการสืบทอดที่ทำให้ช้าลง?


5
ฉันไม่สามารถหาส่วนเฉพาะของซอร์สโค้ด แต่ฉันเชื่อว่ามีเส้นทางที่รวดเร็วในการใช้งาน C ซึ่งตรวจสอบว่าวัตถุเป็นdictและถ้าเป็นเช่นนั้นเรียกการใช้งาน C โดยตรงแทนที่จะมองหา__getitem__วิธีจาก คลาสของวัตถุ ดังนั้นโค้ดของคุณจะทำการค้นหาแบบ Dict สองครั้งอันแรกสำหรับคีย์'__getitem__'ในพจนานุกรมของAสมาชิกคลาสดังนั้นจึงคาดว่าจะช้ากว่าประมาณสองเท่า pickleคำอธิบายอาจจะค่อนข้างคล้าย
kaya3

@ kaya3: แต่ถ้าเป็นเช่นนั้นทำไมlen()ตัวอย่างเช่นไม่ช้ากว่า 2x แต่มีความเร็วเท่ากัน?
Marco Sulla

ฉันไม่แน่ใจเกี่ยวกับเรื่องนี้; ฉันคิดว่าlenควรมีเส้นทางที่รวดเร็วสำหรับชุดลำดับในตัว ฉันไม่คิดว่าฉันสามารถตอบคำถามของคุณได้อย่างถูกต้อง แต่เป็นคำถามที่ดีดังนั้นหวังว่าใครบางคนที่มีความรู้เกี่ยวกับ Python ภายในมากกว่าที่ฉันจะตอบ
kaya3

ฉันได้ทำการตรวจสอบและอัพเดทคำถามแล้ว
Marco Sulla

1
... โอ้ ฉันเห็นมันตอนนี้ ชัดเจนการดำเนินการคือการปิดกั้นตรรกะที่ใช้สำหรับการสืบทอด__contains__ sq_contains
user2357112 รองรับ Monica

คำตอบ:


7

การจัดทำดัชนีและinช้ากว่าในdictคลาสย่อยเนื่องจากการโต้ตอบที่ไม่เหมาะสมระหว่างการปรับให้dictเหมาะสมและคลาสย่อยตรรกะใช้เพื่อสืบทอดสล็อต C สิ่งนี้ควรแก้ไขได้ แต่ไม่ใช่จากจุดจบของคุณ

การติดตั้ง CPython มี hooks สองชุดสำหรับการใช้งานโอเวอร์โหลด มีวิธีการระดับ Python เช่น__contains__และ__getitem__แต่ยังมีชุดของช่องแยกสำหรับตัวชี้ฟังก์ชัน C ในรูปแบบหน่วยความจำของวัตถุชนิด โดยทั่วไปแล้วทั้งวิธี Python จะเป็น wrapper รอบการใช้งาน C หรือสล็อต C จะมีฟังก์ชั่นที่ค้นหาและเรียกวิธีการ Python มีประสิทธิภาพมากขึ้นสำหรับสล็อต C เพื่อใช้งานโดยตรงเนื่องจากสล็อต C คือสิ่งที่ Python เข้าถึงได้จริง

การแม็พที่เขียนใน 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การใช้งานเพียงแค่มี wrapper ชนิดต่าง ๆ )

ปกติเป็น subclass จะสืบทอดการใช้งานแม่ของตะขอระดับ C เช่นsq_containsและmp_subscriptและประเภทรองจะเป็นเพียงให้เร็วที่สุดเท่าซับคลาส อย่างไรก็ตามตรรกะในการupdate_one_slotค้นหาการใช้งานหลักโดยพยายามค้นหาวิธีการ wrapper สร้างขึ้นผ่านการค้นหา MRO

dictไม่ได้สร้าง wrappers สำหรับsq_containsและmp_subscriptเนื่องจากมีการระบุ__contains__และ__getitem__การนำไปใช้งานอย่างชัดเจน

แทนการสืบทอดsq_containsและmp_subscript, update_one_slotปลายขึ้นให้ subclass sq_containsและmp_subscriptการใช้งานที่มีประสิทธิภาพการค้นหา MRO สำหรับ__contains__และ__getitem__และเรียกเหล่านั้น สิ่งนี้มีประสิทธิภาพน้อยกว่าการสืบทอดสล็อต C โดยตรง

การแก้ไขนี้จะต้องมีการเปลี่ยนแปลงในupdate_one_slotการใช้งาน


นอกเหนือจากที่ฉันอธิบายไว้ข้างต้นแล้วdict_subscriptยังค้นหา__missing__subclasses แบบ Dict ด้วยดังนั้นการแก้ไขปัญหาการสืบทอดสล็อตจะไม่ทำให้คลาสย่อยโดยสมบูรณ์พร้อมdictตัวเองเพื่อความเร็วในการค้นหา แต่ควรทำให้พวกเขาเข้าใกล้มากขึ้น


ในฐานะที่เป็นดองในdumpsด้านการดำเนินงานดองมีเส้นทางได้อย่างรวดเร็วโดยเฉพาะสำหรับ dicts ขณะ subclass Dict ใช้เวลาเส้นทางอ้อมมากขึ้นผ่านและobject.__reduce_ex__save_reduce

ในloadsด้านความแตกต่างเวลาส่วนใหญ่จะเป็นเพียงแค่จาก opcodes พิเศษและการค้นหาเพื่อดึงและยกตัวอย่าง__main__.Aระดับในขณะที่ dicts มี 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

เราเห็นว่าความแตกต่างระหว่างทั้งสองคือผักดองตัวที่สองนั้นต้องการ opcode ทั้งหมดเพื่อค้นหา__main__.Aและยกตัวอย่างมันในขณะที่ผักดองตัวแรกนั้นทำEMPTY_DICTเพื่อให้ได้พจน์ว่างเปล่า SETITEMSหลังจากนั้นทั้งผักดองผลักดันปุ่มเดียวกันและค่าลงบนกองดองถูกดำเนินการและเรียกใช้


ขอบคุณมาก! คุณมีความคิดใดบ้างว่าทำไม CPython ใช้วิธีการสืบทอดที่แปลกประหลาดนี้ ฉันหมายความว่าไม่มีวิธีที่จะประกาศ__contains__()และ__getitem()ในลักษณะที่สามารถสืบทอดโดยคลาสย่อยหรือไม่? ในเอกสารอย่างเป็นทางการของtp_methodsมันเขียนว่าmethods are inherited through a different mechanismดังนั้นจึงเป็นไปได้
Marco Sulla

@MarcoSulla: __contains__และ__getitem__ ได้รับการสืบทอดมา แต่ปัญหาคือว่าsq_containsและmp_subscriptไม่ได้
user2357112 รองรับ Monica

อืม .... อืมเดี๋ยวก่อน ฉันคิดว่ามันตรงกันข้าม __contains__และ__getitem__อยู่ในช่องtp_methodsซึ่งสำหรับเอกสารอย่างเป็นทางการไม่ได้รับการสืบทอดโดยคลาสย่อย และเป็นคุณกล่าวว่าupdate_one_slotไม่ได้ใช้และsq_contains mp_subscript
Marco Sulla

ในคำพูดที่ไม่ดีcontainsและที่เหลือไม่สามารถย้ายไปอยู่ในอีกช่องหนึ่งนั่นคือสิ่งที่สืบทอดโดย subclasses?
Marco Sulla

@MarcoSulla: tp_methodsไม่ได้รับการสืบทอด แต่วัตถุของ Python ที่สร้างขึ้นมานั้นได้รับการถ่ายทอดในแง่ที่ว่า MRO มาตรฐานสำหรับการเข้าถึงการเข้าถึงคุณลักษณะจะค้นหาได้
user2357112 รองรับ Monica
โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.