ทำไมรหัสไพ ธ อนจึงทำงานได้เร็วขึ้นในฟังก์ชั่น?


834
def main():
    for i in xrange(10**8):
        pass
main()

รหัสชิ้นนี้ใน Python ทำงานใน (หมายเหตุ: การจับเวลาเสร็จสิ้นด้วยฟังก์ชั่นเวลาใน BASH ใน Linux)

real    0m1.841s
user    0m1.828s
sys     0m0.012s

อย่างไรก็ตามหาก for for ไม่ได้อยู่ในฟังก์ชัน

for i in xrange(10**8):
    pass

จากนั้นมันจะทำงานเป็นเวลานาน:

real    0m4.543s
user    0m4.524s
sys     0m0.012s

ทำไมนี้


16
คุณทำเวลาจริง ๆ ได้อย่างไร?
Andrew Jaffe

53
แค่สัญชาตญาณไม่แน่ใจว่าเป็นเรื่องจริง: ฉันเดาว่ามันเป็นเพราะขอบเขต ในกรณีฟังก์ชั่นขอบเขตใหม่จะถูกสร้างขึ้น (เช่นชนิดของแฮชที่มีชื่อตัวแปรผูกไว้กับค่าของพวกเขา) หากไม่มีฟังก์ชั่นตัวแปรจะอยู่ในขอบเขตส่วนกลางเมื่อคุณสามารถค้นหาสิ่งต่างๆได้มากมายดังนั้นจึงทำให้การวนรอบช้าลง
Scharron

4
@Schron ที่ดูเหมือนจะไม่เป็น กำหนดตัวแปรจำลอง 200k ลงในขอบเขตโดยไม่มีผลกระทบต่อเวลาทำงาน
Deestan

2
Alex Martelli เขียนคำตอบที่ดีเกี่ยวกับstackoverflow.com/a/1813167/174728
John La Rooy

53
@Scharron คุณถูกต้องครึ่งหนึ่ง มันเป็นเรื่องเกี่ยวกับขอบเขต แต่สาเหตุที่เร็วขึ้นในคนท้องถิ่นก็คือขอบเขตในท้องถิ่นนั้นถูกนำไปใช้จริงในฐานะอาร์เรย์แทนที่จะเป็นพจนานุกรม (เนื่องจากขนาดของพวกมันรู้จักกันในเวลารวบรวม)
Katriel

คำตอบ:


532

คุณอาจถามว่าทำไมการเก็บตัวแปรภายในเครื่องให้รวดเร็วกว่า globals นี่เป็นรายละเอียดการติดตั้ง CPython

โปรดจำไว้ว่า CPython นั้นได้รับการคอมไพล์ด้วยไบต์โค้ดซึ่งล่ามทำงาน เมื่อฟังก์ชั่นถูกรวบรวมตัวแปรท้องถิ่นจะถูกเก็บไว้ในอาร์เรย์ขนาดคงที่ ( ไม่ใช่ a dict) และชื่อตัวแปรจะถูกกำหนดให้กับดัชนี สิ่งนี้เป็นไปได้เพราะคุณไม่สามารถเพิ่มตัวแปรท้องถิ่นลงในฟังก์ชันได้แบบไดนามิก จากนั้นการดึงตัวแปรท้องถิ่นเป็นการค้นหาตัวชี้ไปยังรายการและการเพิ่มจำนวน refcount PyObjectซึ่งเป็นเรื่องเล็กน้อย

เปรียบเทียบสิ่งนี้กับการค้นหาทั่วโลก ( LOAD_GLOBAL) ซึ่งเป็นการdictค้นหาจริงที่เกี่ยวข้องกับแฮชและอื่น ๆ นี่คือเหตุผลที่คุณต้องระบุglobal iว่าคุณต้องการให้เป็นโกลบอล: หากคุณเคยกำหนดให้กับตัวแปรภายในขอบเขตคอมไพเลอร์จะออกSTORE_FASTs สำหรับการเข้าถึงเว้นแต่คุณจะไม่บอก

อย่างไรก็ตามการค้นหาทั่วโลกยังคงได้รับการปรับให้เหมาะสม การค้นหาคุณสมบัติfoo.barนั้นเป็นสิ่งที่ช้ามาก!

นี่คือภาพประกอบเล็ก ๆเกี่ยวกับประสิทธิภาพของตัวแปรท้องถิ่น


6
สิ่งนี้ยังใช้กับ PyPy จนถึงรุ่นปัจจุบัน (1.8 ในขณะที่เขียนนี้) รหัสทดสอบจาก OP จะทำงานช้าลงประมาณสี่เท่าในขอบเขตส่วนกลางเมื่อเทียบกับภายในฟังก์ชั่น
GDorn

4
@Walkerneo พวกเขาไม่ได้เว้นแต่คุณจะพูดถอยหลัง ตามสิ่งที่ katrielalex และ ecatmur กำลังพูดการค้นหาตัวแปรทั่วโลกช้ากว่าการค้นหาตัวแปรท้องถิ่นเนื่องจากวิธีการจัดเก็บ
Jeremy Pridemore

2
@Walkerneo บทสนทนาหลักที่เกิดขึ้นที่นี่เป็นการเปรียบเทียบระหว่างการค้นหาตัวแปรโลคัลภายในฟังก์ชันและการค้นหาตัวแปรโกลบอลที่กำหนดไว้ที่ระดับโมดูล หากคุณสังเกตเห็นในความคิดเห็นดั้งเดิมของคุณตอบกลับคำตอบนี้คุณพูดว่า "ฉันไม่คิดว่าการค้นหาตัวแปรทั่วโลกจะเร็วกว่าการค้นหาคุณสมบัติตัวแปรท้องถิ่น" และพวกเขาไม่ได้ katrielalex กล่าวว่าแม้ว่าการค้นหาตัวแปรภายในเครื่องจะเร็วกว่าการค้นหาทั่วโลก แต่การค้นหาทั่วโลกจะได้รับการปรับให้เหมาะสมและเร็วกว่าการค้นหาแอตทริบิวต์ (ซึ่งแตกต่างกัน) ฉันไม่มีพื้นที่เพียงพอในความคิดเห็นนี้อีกต่อไป
Jeremy Pridemore

3
@Walkerneo foo.bar ไม่ใช่การเข้าถึงในระบบ มันเป็นคุณสมบัติของวัตถุ (ยกโทษให้ขาดการจัดรูปแบบ) def foo_func: x = 5, xเป็นท้องถิ่นเพื่อฟังก์ชั่น การเข้าถึงxเป็นท้องถิ่น foo = SomeClass(), foo.barคือการเข้าถึงแอตทริบิวต์ val = 5ทั่วโลกคือทั่วโลก สำหรับคุณลักษณะ local> global> speed ตามที่ฉันได้อ่านที่นี่ ดังนั้นการเข้าถึงxในการfoo_funcเป็นที่เร็วที่สุดตามด้วยตามด้วยval ไม่ใช่การค้นหาในเครื่องเนื่องจากในบริบทของคอนโดนี้เรากำลังพูดถึงการค้นหาในเครื่องที่เป็นการค้นหาตัวแปรที่เป็นของฟังก์ชัน foo.barfoo.attr
Jeremy Pridemore

3
@thedoctar ได้ดูglobals()ฟังก์ชั่น หากคุณต้องการข้อมูลมากกว่านั้นคุณอาจต้องเริ่มดูซอร์สโค้ดสำหรับ Python และ CPython เป็นเพียงชื่อสำหรับการนำ Python ไปใช้ตามปกติดังนั้นคุณอาจใช้มันอยู่แล้ว!
Katriel

661

ในฟังก์ชั่น bytecode คือ:

  2           0 SETUP_LOOP              20 (to 23)
              3 LOAD_GLOBAL              0 (xrange)
              6 LOAD_CONST               3 (100000000)
              9 CALL_FUNCTION            1
             12 GET_ITER            
        >>   13 FOR_ITER                 6 (to 22)
             16 STORE_FAST               0 (i)

  3          19 JUMP_ABSOLUTE           13
        >>   22 POP_BLOCK           
        >>   23 LOAD_CONST               0 (None)
             26 RETURN_VALUE        

ในระดับสูงสุด bytecode คือ:

  1           0 SETUP_LOOP              20 (to 23)
              3 LOAD_NAME                0 (xrange)
              6 LOAD_CONST               3 (100000000)
              9 CALL_FUNCTION            1
             12 GET_ITER            
        >>   13 FOR_ITER                 6 (to 22)
             16 STORE_NAME               1 (i)

  2          19 JUMP_ABSOLUTE           13
        >>   22 POP_BLOCK           
        >>   23 LOAD_CONST               2 (None)
             26 RETURN_VALUE        

ความแตกต่างก็คือว่าSTORE_FASTเร็ว (!) STORE_NAMEกว่า นี่เป็นเพราะในฟังก์ชั่นiเป็นท้องถิ่น แต่ที่ระดับบนสุดมันเป็นระดับโลก

เพื่อตรวจสอบ bytecode ใช้โมดูลdis ผมสามารถที่จะถอดแยกชิ้นส่วนฟังก์ชั่นโดยตรง แต่จะถอดรหัสระดับบนสุดที่ฉันมีที่จะใช้ในตัวcompile


171
ยืนยันโดยการทดสอบ การแทรกglobal iเข้าไปในmainฟังก์ชั่นทำให้เวลาในการทำงานเท่ากัน
Deestan

44
นี่เป็นการตอบคำถามโดยไม่ตอบคำถาม :) ในกรณีของตัวแปรฟังก์ชั่นท้องถิ่น CPython จริงเก็บเหล่านี้ใน tuple (ซึ่งไม่แน่นอนจากรหัส C) จนกว่าจะมีการร้องขอพจนานุกรม (เช่นผ่านlocals()หรือinspect.getframe()อื่น ๆ ) การค้นหาองค์ประกอบอาร์เรย์ด้วยจำนวนเต็มคงที่นั้นเร็วกว่าการค้นหา dict
dmw

3
มันก็เหมือนกันกับ C / C ++ เช่นกันโดยใช้ตัวแปรทั่วโลกทำให้เกิดการชะลอตัวที่สำคัญ
codejammer

3
นี่เป็นครั้งแรกที่ฉันได้เห็น bytecode .. คนเรามองมันอย่างไรและมีความสำคัญอย่างไรที่จะรู้
Zack

4
@gkimsey ฉันเห็นด้วย แค่อยากจะแบ่งปันสองสิ่ง i) พฤติกรรมนี้ถูกบันทึกไว้ในภาษาการเขียนโปรแกรมอื่น ๆ ii) เอเจนต์เชิงสาเหตุเป็นด้านสถาปัตยกรรมมากกว่าและไม่ใช่ภาษาในความหมายที่แท้จริง
codejammer

41

นอกเหนือจากเวลาเก็บตัวแปรท้องถิ่น / ส่วนกลางการคาดการณ์ opcodeทำให้ฟังก์ชั่นเร็วขึ้น

ตามที่คำตอบอื่น ๆ อธิบายฟังก์ชั่นใช้STORE_FASTopcode ในลูป นี่คือโค้ดไบต์สำหรับการวนซ้ำของฟังก์ชัน:

    >>   13 FOR_ITER                 6 (to 22)   # get next value from iterator
         16 STORE_FAST               0 (x)       # set local variable
         19 JUMP_ABSOLUTE           13           # back to FOR_ITER

โดยปกติเมื่อมีการเรียกใช้โปรแกรม Python จะดำเนินการแต่ละ opcode ซึ่งกันและกันติดตามการทำงานของ stack และ preforming การตรวจสอบอื่น ๆ ใน stack frame หลังจากดำเนินการแต่ละ opcode การคาดการณ์ของ Opcode หมายความว่าในบางกรณี Python สามารถข้ามไปยัง opcode ถัดไปได้โดยตรงดังนั้นจึงไม่ควรใช้โอเวอร์เฮดนี้

ในกรณีนี้ทุกครั้งที่ Python เห็นFOR_ITER(ด้านบนของลูป) มันจะ "ทำนาย" ซึ่งSTORE_FASTเป็น opcode ถัดไปที่จะต้องดำเนินการ งูหลามแล้ว peeks ที่ opcode STORE_FASTต่อไปและหากการทำนายถูกต้องมันกระโดดตรงไปยัง นี่คือผลของการบีบสอง opcode เป็น opcode เดียว

ในทางตรงกันข้ามSTORE_NAMEopcode จะถูกใช้ในการวนซ้ำในระดับโลก Python ไม่ *ทำการคาดคะเนที่คล้ายกันเมื่อเห็น opcode นี้ แต่จะต้องกลับไปที่ด้านบนของลูปการประเมินผลซึ่งมีผลกระทบอย่างชัดเจนต่อความเร็วในการดำเนินการลูป

หากต้องการให้รายละเอียดทางเทคนิคเพิ่มเติมเกี่ยวกับการเพิ่มประสิทธิภาพนี้ต่อไปนี้เป็นคำกล่าวจากceval.cไฟล์ ("เอ็นจิ้น" ของเครื่องเสมือนของ Python):

opcodes บางตัวมีแนวโน้มที่จะมาเป็นคู่จึงทำให้สามารถทำนายรหัสที่สองเมื่อรันครั้งแรก ยกตัวอย่างเช่น มักจะตามมาด้วยGET_ITER FOR_ITERและFOR_ITERมักจะตามมาด้วยSTORE_FASTUNPACK_SEQUENCEหรือ

การตรวจสอบการคาดการณ์ทำให้เสียค่าใช้จ่ายในการทดสอบความเร็วสูงเดียวของตัวแปร register กับค่าคงที่ หากการจับคู่เป็นไปได้ดีการคาดคะเนสาขาภายในของโปรเซสเซอร์จะมีโอกาสสูงที่จะประสบความสำเร็จส่งผลให้เกิดการเปลี่ยนแปลงเกือบเป็นศูนย์เหนือ opcode ถัดไป การทำนายที่ประสบความสำเร็จจะช่วยประหยัดการเดินทางผ่านการวนรอบ eval รวมทั้งกิ่งไม้ที่ไม่สามารถคาดการณ์ได้สองแบบการHAS_ARGทดสอบและตัวเรือนสวิตช์ เมื่อรวมกับการทำนายสาขาภายในของตัวประมวลผลความสำเร็จPREDICTมีผลทำให้การใช้งานสอง opcode ราวกับว่าพวกมันเป็น opcode ใหม่เพียงครั้งเดียว

เราสามารถเห็นได้ในซอร์สโค้ดสำหรับFOR_ITERopcode ตรงกับที่การคาดการณ์STORE_FASTทำ:

case FOR_ITER:                         // the FOR_ITER opcode case
    v = TOP();
    x = (*v->ob_type->tp_iternext)(v); // x is the next value from iterator
    if (x != NULL) {                     
        PUSH(x);                       // put x on top of the stack
        PREDICT(STORE_FAST);           // predict STORE_FAST will follow - success!
        PREDICT(UNPACK_SEQUENCE);      // this and everything below is skipped
        continue;
    }
    // error-checking and more code for when the iterator ends normally                                     

PREDICTขยายตัวฟังก์ชั่นif (*next_instr == op) goto PRED_##opคือเราก็ข้ามไปยังจุดเริ่มต้นของ opcode ที่คาดการณ์ไว้ ในกรณีนี้เรากระโดดที่นี่:

PREDICTED_WITH_ARG(STORE_FAST);
case STORE_FAST:
    v = POP();                     // pop x back off the stack
    SETLOCAL(oparg, v);            // set it as the new local variable
    goto fast_next_opcode;

ตัวแปรโลคอลได้รับการตั้งค่าแล้วและ opcode ถัดไปจะพร้อมใช้งานสำหรับการดำเนินการ Python ดำเนินการต่อผ่าน iterable จนกว่าจะถึงจุดสิ้นสุดทำให้การทำนายผลสำเร็จทุกครั้ง

หน้าวิกิพีเดียหลามมีข้อมูลเพิ่มเติมเกี่ยวกับวิธีการ CPython ทำงานของเครื่องเสมือน


การอัปเดตย่อย: ตั้งแต่ CPython 3.6 การประหยัดจากการคาดการณ์ลดลงเล็กน้อย แทนที่จะเป็นสองกิ่งที่ไม่สามารถคาดเดาได้มีเพียงสาขาเดียวเท่านั้น การเปลี่ยนแปลงเกิดจากการสลับจาก bytecode เป็น wordcode ; ตอนนี้ "wordcodes" ทั้งหมดมีอาร์กิวเมนต์มันเป็นแค่ศูนย์ -ed เมื่อคำสั่งไม่ได้ใช้เหตุผลโต้แย้ง ดังนั้นการHAS_ARGทดสอบจะไม่เกิดขึ้น (ยกเว้นเมื่อเปิดใช้การติดตามระดับต่ำทั้งในคอมไพล์และรันไทม์ซึ่งไม่มีการสร้างปกติ) ทำให้เหลือเพียงการกระโดดที่ไม่แน่นอน
ShadowRanger

แม้กระทั่งว่ากระโดดคาดเดาไม่ได้ไม่ได้เกิดขึ้นในส่วนของการสร้าง CPython เพราะของใหม่ ( เป็นของงูใหญ่ 3.1 , เปิดใช้งานโดยค่าเริ่มต้นใน 3.2 ) คำนวณพฤติกรรม gotos; เมื่อใช้งานPREDICTมาโครจะถูกปิดการใช้งานอย่างสมบูรณ์ แต่กรณีส่วนใหญ่จะลงท้ายด้วยDISPATCHสาขานั้นโดยตรง แต่ในการคาดคะเนซีพียูของสาขานั้นเอฟเฟกต์จะคล้ายกับของPREDICTเนื่องจากการแยกย่อย (และการทำนาย) เป็นต่อ opcode เพิ่มอัตราต่อรองของการทำนายสาขาที่ประสบความสำเร็จ
ShadowRanger
โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.