ประสิทธิภาพของอินเทอร์พรีเตอร์แบบ tail call ใน Python 3.14
(blog.nelhage.com)- อินเทอร์พรีเตอร์แบบ tail call ใหม่ของ CPython ในตอนแรกดูเหมือนจะให้ประสิทธิภาพเฉลี่ยใน pyperformance ดีขึ้น 10~15% แต่เมื่อปรับ baseline แล้ว การปรับปรุงจริงจะแคบลงเหลือประมาณ 1~5% ตามการตั้งค่า
- การปรับปรุงที่ดูมากนั้นน่าจะเป็นผลจากการหลบเลี่ยง regression ของ LLVM 19 มากกว่าจะเป็นผลของการทำงานใหม่เพียงอย่างเดียว โดยปัจจัยสำคัญคือ Clang 19 ไม่สามารถทำซ้ำ dispatch แบบ computed goto เดิมได้อย่างถูกต้อง
- บน Intel Raptor Lake i5-13500 บิลด์ clang19 ช้ากว่า clang18 1.09 เท่า และ clang19.tc เร็วกว่า 1.03 เท่า แต่บน Apple M1 MacBook Air clang19 ช้ากว่า 1.12 เท่า และ clang19.tc ก็ยังอยู่ระดับช้ากว่า 1.00 เท่า
- ด้วย ข้อจำกัดของ tail duplication ใน LLVM จำนวน indirect jump ลดจาก 332 จุดใน clang18 เหลือ 3 จุดใน clang19 ทำให้โครงสร้างที่อินเทอร์พรีเตอร์แบบ computed goto ตั้งใจไว้แทบหายไป
- แนวทาง tail call ยังคงเป็นการปรับปรุงที่มีความหมาย แต่แนวทางที่ระบุอย่างชัดเจนว่าต้องพึ่งพาการ optimize ของคอมไพเลอร์ เช่น
musttailอาจแข็งแรงกว่าสำหรับโค้ดที่อ่อนไหวต่อประสิทธิภาพ
ผลของ baseline ที่ดูเหมือนเป็นการปรับปรุงประสิทธิภาพ
- โปรเจกต์ CPython ได้ merge กลยุทธ์การทำงานใหม่ สำหรับ bytecode interpreter เมื่อประมาณหนึ่งเดือนก่อน
- ผลลัพธ์ช่วงแรกแสดง ประสิทธิภาพเพิ่มขึ้น 10~15% โดยเฉลี่ยบนหลายแพลตฟอร์มและ benchmark pyperformance
- การวิเคราะห์ในภายหลังพบว่าการเพิ่มขึ้นอย่างมากนี้ส่วนใหญ่เป็นผลจากการ หลบเลี่ยง regression ของ LLVM 19 โดยบังเอิญ
- เมื่อเทียบกับ GCC, clang-18 หรือ LLVM 19 ที่ใช้ flag สำหรับ tuning บางอย่าง ระดับการปรับปรุงลดลงมาอยู่ราว 1~5%
- ตัวอินเทอร์พรีเตอร์แบบ tail call เองทำให้ความเร็วเพิ่มขึ้นจริง แต่ระดับการปรับปรุงนุ่มนวลกว่าที่ตัวเลขช่วงแรกสื่อไว้
- หากบิลด์ด้วย clang-19 หรือเวอร์ชันหลังจากนั้น เส้นทางเดิมอาจช้าลงจริง 10~15%
- Simon Willison ทำซ้ำผลลัพธ์ความเร็วเพิ่มขึ้น 10% ได้ในการเทียบระหว่างบิลด์
python-build-standaloneกับ Python 3.13
- Simon Willison ทำซ้ำผลลัพธ์ความเร็วเพิ่มขึ้น 10% ได้ในการเทียบระหว่างบิลด์
โครงสร้าง benchmark และตัวเลขสำคัญ
- เปรียบเทียบบิลด์ CPython หลายแบบบนเซิร์ฟเวอร์ Intel และ Apple M1 MacBook Air
- เซิร์ฟเวอร์ Intel คือ Raptor Lake i5-13500 ที่รันอยู่บน Hetzner
- ทุกบิลด์ใช้ LTO และ PGO
- ใช้ คอนฟิก nix เพื่อให้บิลด์ซ้ำได้
- รายการที่นำมาเปรียบเทียบมีดังนี้
clang18: Clang 18.1.8, computed gotogcc: GCC 14.2.1, computed goto, เฉพาะ Intelclang19: Clang 19.1.7, computed gotoclang19.tc: Clang 19.1.7, อินเทอร์พรีเตอร์แบบ tail call ใหม่clang19.taildup: Clang 19.1.7, computed goto และ flag tuning-mllvmสำหรับหลบ regression
- ผลเฉลี่ย pyperformance โดยใช้
clang18เป็น baseline มีดังนี้- Raptor Lake i5-13500:
clang19: ช้ากว่า 1.09 เท่าclang19.taildup: เร็วกว่า 1.01 เท่าclang19.tc: เร็วกว่า 1.03 เท่าgcc: เร็วกว่า 1.02 เท่า
- Apple M1 MacBook Air:
clang19: ช้ากว่า 1.12 เท่าclang19.taildup: ช้ากว่า 1.02 เท่าclang19.tc: ช้ากว่า 1.00 เท่า
- Raptor Lake i5-13500:
- อินเทอร์พรีเตอร์แบบ tail call แสดงความเร็วเพิ่มขึ้นบางส่วนเมื่อเทียบกับ clang-18 แต่ยังน้อยกว่าการลดลงของประสิทธิภาพที่เกิดจากการย้ายไป clang-19
- ไม่สามารถวัด
clang18.tcได้- เพราะอินเทอร์พรีเตอร์แบบ tail call พึ่งพา ฟีเจอร์คอมไพเลอร์ที่เพิ่งเพิ่มใน Clang 19
- ข้อจำกัดนี้ทำให้ต้องมีชุด benchmark มากขึ้นเพื่อทำความเข้าใจสถานการณ์
การพังทลายของ dispatch จาก regression ของ LLVM 19
- bytecode interpreter แบบดั้งเดิมประมวลผล opcode ด้วยคำสั่ง
switchภายในลูปwhile- โดยปกติคอมไพเลอร์จะคอมไพล์
switchเป็น jump table และ indirect jump
- โดยปกติคอมไพเลอร์จะคอมไพล์
- เป็นที่รู้กันมานานว่าการทำซ้ำ logic ของ dispatch ไว้ใน body ของแต่ละ opcode สามารถทำให้อินเทอร์พรีเตอร์ประเภทนี้เร็วขึ้นได้
- แทนที่จะกลับไปต้นลูปหลังจบ opcode แต่จะให้ logic สำหรับ decode คำสั่งถัดไปและ index jump table อยู่แยกในแต่ละ opcode
- คอมไพเลอร์ C มีฟีเจอร์ที่นำ address ของ label มาใช้เป็น computed goto ได้ และก่อนงาน tail call CPython ก็ใช้ลูปอินเทอร์พรีเตอร์แบบนี้
- Clang/LLVM รวม
gotoหลายจุดของ computed goto ภายในให้เป็น indirectbr LLVM instruction เดียว ด้วยเหตุผลด้านประสิทธิภาพของคอมไพเลอร์- จากนั้นในขั้นตอน code generation จะทำ tail duplication เพื่อคัดลอก logic การ branch กลับไปยังแต่ละตำแหน่ง
- โฟลว์นี้ถูกอธิบายไว้ในระดับสูงใน บล็อกโพสต์เก่าของ LLVM
- LLVM 19 นำ ข้อจำกัดของ tail duplication pass เข้ามาเพื่อหลีกเลี่ยงกรณีที่เวลา compile หรือการใช้หน่วยความจำเพิ่มขึ้นอย่างรุนแรงในบางกรณี
- ใน CPython ข้อจำกัดนี้ทำให้ Clang ปล่อย dispatch jump ไว้ในสภาพที่ถูกรวมกัน
- ผลคือเป้าหมายของ implementation แบบ computed goto แทบถูกทำให้ไร้ผล
- ปัญหานี้ถูก ระบุพบก่อน ใน implementation ของภาษาอื่นที่มีลูปอินเทอร์พรีเตอร์คล้ายกัน แต่ยังไม่เป็นที่รู้ว่ามันกระทบ CPython
- เมื่อ disassemble object code แล้วนับจำนวน indirect jump จะเห็นความแตกต่างโดยตรง
_PyEval_EvalFrameDefaultในบิลด์ clang18:jmp *332 จุด_PyEval_EvalFrameDefaultในบิลด์ clang19:jmp *3 จุด
ตำแหน่งที่คลุมเครือของ computed goto
- ยืนยันได้ว่าการเปลี่ยนแปลง logic ของ tail duplication เป็นสาเหตุของ regression จากข้อเท็จจริงที่ว่าหลังแก้ไขแล้วประสิทธิภาพกลับมาอยู่ระดับ clang-18
- อย่างไรก็ตาม ขนาด ของ regression ยังไม่ได้รับการอธิบายทั้งหมด
- ในอดีตเคยมีการอ้างว่าการทำซ้ำ opcode dispatch ทำให้อินเทอร์พรีเตอร์เร็วขึ้นได้ตั้งแต่ 20% ถึง 100%
- บนโปรเซสเซอร์สมัยใหม่ที่มี branch predictor ดีขึ้น งานวิจัยใหม่กว่าพบการปรับปรุงที่น้อยลงราว 2~4%
- Python ยังรองรับอินเทอร์พรีเตอร์แบบเก่าที่ใช้คำสั่ง
switchเดียวผ่าน option การตั้งค่าclang18.nocg: เร็วกว่า clang18 1.01 เท่าclang19.nocg: ช้ากว่า clang18 1.02 เท่าclang19: ช้ากว่า clang18 1.09 เท่า
- การที่
clang19.nocgเร็วกว่าclang19เป็นอีกจุดที่พลิกความคาดหมาย- Clang 18 หรือ Clang 19 ที่ใช้ flag เหมาะสม จะทำซ้ำ logic dispatch ใน body ของแต่ละ opcode แม้กับอินเทอร์พรีเตอร์แบบ
switch
- Clang 18 หรือ Clang 19 ที่ใช้ flag เหมาะสม จะทำซ้ำ logic dispatch ใน body ของแต่ละ opcode แม้กับอินเทอร์พรีเตอร์แบบ
- การเปรียบเทียบจำนวน indirect jump ก็เผยให้เห็นความต่างนี้
clang18: 332 จุดclang18.nocg: 306 จุดclang19.nocg: 3 จุดclang19: 3 จุด
- ใน Clang สมัยใหม่ อินเทอร์พรีเตอร์แบบ computed goto ทั้งหมดอาจเป็นความซับซ้อนที่ไม่จำเป็น
- เพราะคอมไพเลอร์สามารถทำ transformation เดียวกันกับโค้ดที่ใช้
switchได้ด้วย - ในทางกลับกัน computed goto เองก็ไม่เพียงพอที่จะรับประกัน transformation
- เพราะคอมไพเลอร์สามารถทำ transformation เดียวกันกับโค้ดที่ใช้
- GCC 14.2.1 ไม่ได้ทำซ้ำ
switchแต่เมื่อใช้ computed goto ก็ทำงานตามที่ตั้งใจไว้
การแก้ไขและวิธีหลบเลี่ยง
- LLVM pull request 114990 ถูก merge หลังจากโพสต์บทความไม่นานเพื่อแก้ regression นี้
- benchmark ก่อนการ merge ก็ยืนยันว่าการแก้ไขดังกล่าวกู้ประสิทธิภาพตามที่คาดไว้ได้
- ใน release ก่อนแก้ไข สามารถปรับ threshold การหยุด tail duplication ได้ด้วย tuning option ที่ PR ซึ่งทำให้เกิด regression เพิ่มเข้ามา
- หากตั้งข้อจำกัดนั้นใน clang-19 ให้เป็นค่าที่ใหญ่มาก ก็สามารถกู้พฤติกรรมคล้ายเดิมกลับมาได้
- สำหรับบิลด์ LTO การส่ง option นี้ซับซ้อน
- tail duplication เกิดขึ้นระหว่าง code generation และ code generation ของบิลด์ LTO เกิดขึ้นที่ link time ไม่ใช่ compile time
- ดังนั้นต้องส่ง flag ให้ทั้งคอมไพเลอร์และ
lld
- ตัวอย่างการตั้งค่าที่ใช้คือการส่ง
-mllvm -tail-dup-pred-size=5000ให้กับOPTและLDFLAGSในขั้นตอน./configure
ปัญหา baseline ที่ benchmark เผยให้เห็น
- benchmark สามารถวัดความต่างของประสิทธิภาพระหว่างบิลด์เฉพาะชุดได้อย่างแม่นยำ แต่หากจะขยายผลลัพธ์นั้นเป็น “การปรับปรุงประสิทธิภาพทั่วไป” ต้องมีสมมติฐานเพิ่มเติม
- benchmark ของอินเทอร์พรีเตอร์แบบ tail call แสดงผลว่าเร็วกว่าตัวอินเทอร์พรีเตอร์ computed goto เดิม 10~15% แต่ baseline มีความซับซ้อนเกินกว่าจะสรุปเป็นข้อสรุปกว้าง ๆ ได้ง่าย
- ในงานด้านประสิทธิภาพ สิ่งที่ใช้เป็น baseline ในการเปรียบเทียบ เป็นปัญหาที่ยากซ้ำแล้วซ้ำเล่า
- แม้จะเข้าใจเชิงทฤษฎีถึงแนวทางที่ดีที่สุดเท่าที่รู้ในปัจจุบัน แต่การปรับ OS, compiler option และ flag ให้เหมาะสมในโลกจริงก็เป็นอีกเรื่องหนึ่ง
- benchmark สาธารณะที่มาจากฮาร์ดแวร์เก่า หรือจากสเกลที่ทำซ้ำได้ยาก อาจไม่เหมาะกับการเปรียบเทียบโดยตรง
- ในงานวิจัย machine learning เวลาจะอ้างว่าอัลกอริทึมดีขึ้น คำถามที่สำคัญก่อน “ทำอะไรไป” มักเป็น “เทียบกับ baseline แบบไหน”
- หากเทียบกับ baseline ที่ tune ผิด ก็สร้างผลลัพธ์ที่ดูน่าประทับใจได้ง่าย
Optimizing compiler และ musttail
- กรณี computed goto แสดงให้เห็นว่าความคาดหวังต่อ optimizing compiler อาจขัดแย้งกันเองได้
- คอมไพเลอร์ควรเคารพเจตนาของโปรแกรมเมอร์และรักษาพฤติกรรมเดิมไว้
- ขณะเดียวกันก็ต้องทำ transformation ที่ซับซ้อนและไม่เป็นสัญชาตญาณเพื่อให้โค้ดเร็วขึ้น
- clang-19 คอมไพล์อินเทอร์พรีเตอร์แบบ computed goto ได้ถูกต้องในแง่พฤติกรรมของโปรแกรม แต่สร้าง output ที่ต่างจากเจตนาในการ optimize อย่างสิ้นเชิง
- Clang เวอร์ชันอื่นใช้ optimization แบบเดียวกับที่ตั้งใจไว้ได้แม้กับอินเทอร์พรีเตอร์ที่ใช้
switch()ธรรมดา - computed goto ในระดับ source code กับการทำซ้ำ dispatch ในระดับ machine code ดูแทบเป็นแนวคิดที่ตั้งฉากกัน
- เพราะผลลัพธ์การรันเหมือนกัน เครื่องมือปัจจุบันจึงแสดงความต่างนี้อย่างสม่ำเสมอได้ยาก
- อินเทอร์พรีเตอร์แบบ tail call อิงกับ musttail attribute
musttailไม่ได้เปลี่ยนพฤติกรรมโปรแกรมที่สังเกตได้แบบดั้งเดิม แต่ใกล้เคียงกับ การสนทนากับ optimizer- คอมไพเลอร์ต้องสามารถทำ optimization ที่เจาะจงได้ และหาก optimization นั้นไม่เกิดขึ้น ต้องให้การคอมไพล์ล้มเหลว
- แนวทางแบบนี้อาจเป็นสไตล์การเขียนโค้ดที่อ่อนไหวต่อประสิทธิภาพให้แข็งแรงขึ้นได้ แม้คอมไพเลอร์จะพัฒนาเปลี่ยนไป
- น่าพิจารณาด้วยว่า attribute สมมติอย่าง
[[clang::musttailduplicate]]อาจใช้แทน computed goto ของลูปwhileในอินเทอร์พรีเตอร์ได้หรือไม่
ความสามารถในการทำซ้ำและข้อจำกัดที่ nix ให้มา
nixช่วยได้มากในการจัดการบิลด์ Python interpreter หลายชุด- ระหว่างการทดลอง มีการบิลด์และ benchmark Python interpreter หลายสิบตัวด้วยคอมไพเลอร์สี่ตัว ได้แก่
gcc,clang-18,clang-19,clang-20และชุด flag หลากหลายแบบ
- ระหว่างการทดลอง มีการบิลด์และ benchmark Python interpreter หลายสิบตัวด้วยคอมไพเลอร์สี่ตัว ได้แก่
- การใช้
nixทำให้สามารถรักษาเวอร์ชันคู่ขนานหลายชุดในแบบที่ทำซ้ำได้และแยกกันชัดเจน- สามารถมั่นใจได้ว่าบิลด์หนึ่ง ๆ มาจากคอมไพเลอร์และ flag ใด
- นิยาม build matrix ก็จัดการได้ด้วย abstraction สั้น ๆ
- การบิลด์ LLVM แบบ custom ที่ใส่ patch แก้บั๊ก แล้วใช้คอมไพเลอร์นั้นบิลด์ Python ต่อ ก็ทำได้ด้วยโค้ดประมาณ 10 บรรทัด
- ยังมีข้อเสียอยู่
nixมีบางส่วนที่ต่างจากวิธีใช้ซอฟต์แวร์ทั่วไป จึงตัดความเป็นไปได้ทั้งหมดได้ยากว่าความต่างเหล่านี้อาจมีผลต่อ benchmark หรือข้อสรุป- ตัวอย่างเช่น โดยค่าเริ่มต้น nix จะบิลด์โปรเจกต์ด้วย hardening flag บางอย่าง และช่วงแรกพบว่า flag นี้ ส่งผลอย่างไม่สมดุล ต่ออินเทอร์พรีเตอร์แบบ tail call
- Nix มีความสามารถในการขยายและปรับแต่งสูง แต่การหาวิธีปรับแต่งเฉพาะทางต้องลองผิดลองถูกจำนวนมากและต้องค้นซอร์สของ
nixpkgs
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
ผมเป็นผู้เขียน PR ที่ใส่ tail-call interpreter เข้าไปใน CPython
ก่อนอื่นต้องขอบคุณ Nelson ที่ใช้เวลาเกือบหนึ่งเดือนในการหาต้นตอของปัญหานี้
ทั้งผม และน่าจะรวมถึงทีม CPython ด้วย ไม่ได้คาดคิดเลยว่าคอมไพเลอร์ที่ใช้เป็น baseline จะมีบั๊กแบบนั้น ผมทำพลาดครั้งใหญ่ จึงอายและเสียใจมาก
ผมได้โพสต์คำขอโทษไว้ด้วย: https://fidget-spinner.github.io/posts/apology-tail-call.htm...
มีการประกาศว่า ประสิทธิภาพดีขึ้น 10~15% แต่กับคอมไพเลอร์ที่ไม่มีบั๊ก ตัวเลขน่าจะใกล้ 1~5% มากกว่า และตัวเลขเดิมก็ไม่ได้ผิดไปหมด เพียงแต่ถูกต้องเฉพาะในเงื่อนไขบางอย่างเท่านั้น
คุณได้สร้างการปรับปรุง วัดผล และให้ PR ผ่านการรีวิวแล้ว ก็ถือว่าทำสิ่งที่ควรทำแล้วพอดี ปัญหาเป็นเพราะเวอร์ชันของ clang ที่ใช้วัดผลทำให้ตัวเลขชวนเข้าใจผิด ซึ่งดูเป็นความผิดพลาดที่สมเหตุสมผลและใคร ๆ ก็อาจเจอได้
ถึงอย่างนั้นก็ยังนำมาซึ่งการปรับปรุงประสิทธิภาพที่มีความหมาย และยังค้นพบ regression ของคอมไพเลอร์ด้วย เมื่อเทียบกันแล้วตัวเลขที่ผิดดูเป็นเรื่องเล็ก ผมก็ไม่ค่อยแน่ใจด้วยว่ามีใครได้รับความเสียหายจริง ๆ จากเรื่องนี้ และไม่น่าถึงขั้นต้องขอโทษ
ถ้า 3~5% ยังรักษาไว้ได้ในระบบเก่าแก่แบบ Python interpreter แค่นั้นก็ถือเป็นความสำเร็จใหญ่และควรภูมิใจได้เต็มที่
พอผ่านมาราว 30 ปี ผมก็เริ่มสงสัยไว้ก่อนกับการปรับปรุงประสิทธิภาพที่มีความหมายในระบบที่อยู่มานาน โดยเฉพาะการปรับปรุงที่เกิน 1%
การปรับปรุงจริง ๆ ก็มีอยู่บ้าง แต่ไม่ค่อยพบ และบ่อยครั้งเป็นแค่การย้ายเวลาไปไว้ที่อื่น จน benchmark จับไม่ได้ อีกอย่าง benchmark มักทำในสภาพแวดล้อมที่ควบคุมเพื่อแยกผลออกมา แต่ซอฟต์แวร์จริงรันอยู่บน VM หรือเดสก์ท็อปร่วมกับสารพัดอย่างอื่น
ผมเห็นมาหลายครั้งแล้วว่า optimization ที่ดูชัดเจนและใหญ่ในสภาพแวดล้อมที่แยกออกมา พอเข้า production แล้วกลับหายไปหรือกลายเป็นค่าลบ
CPython ยากยิ่งกว่านั้นเพราะต้องรองรับหลายสภาพแวดล้อม และไม่มีเป้าหมาย production เดี่ยว ๆ ที่จะพูดได้ว่า “ถ้าใน production ไม่เร็วขึ้น ก็ไม่ได้เร็วขึ้นจริง” การพยายามปรับปรุงประสิทธิภาพในโลกแบบนั้นยากมากจริง ๆ
สุดท้ายแล้ว การจูนและวัดประสิทธิภาพ เป็นเรื่องยากมาก และสิ่งเดียวที่ควรรู้สึกขอโทษก็คงเป็นแค่การได้เรียนรู้ข้อเท็จจริงนั้น
อยากให้ไม่กลัวการผิดพลาด เพราะยังไงทุกคนก็ผิดพลาดได้ ทำแบบตอนนี้ก็พอ คือพูดว่า “ดูเหมือนนี่เป็นสิ่งที่เราทำพัง” แล้วหาวิธีจัดการ รวมถึงวิธีหลีกเลี่ยงในอนาคต
[1] ไม่ใช่แค่เรื่องประสิทธิภาพ แต่ในกระบวนการของคนก็พบได้บ่อย เช่น ทีมเครื่องมือ code review อาจบอกว่า “เราลดเวลา code review ได้ 15% ทำให้ workflow ของทุกคนเร็วขึ้น” แต่จริง ๆ แล้วอาจไปสร้างงานเพิ่มในส่วนอื่นของระบบ ทำให้ flow โดยรวมไม่ได้เร็วขึ้น และเพียงย้าย 15% ไปยังจุดที่ไม่ได้วัด
ตามทฤษฎีแล้ว ถ้ามี control-flow graph และ profile แบบนี้ คอมไพเลอร์ก็ควรมีข้อมูลเพียงพอที่จะสร้างโค้ดที่เหมาะที่สุดให้ interpreter แบบดั้งเดิมที่ใช้
switch()แต่ในทางปฏิบัติ เมื่อฟังก์ชันใหญ่และเชื่อมโยงกันแบบนี้ คุณจะลงเอยด้วยการต่อสู้กับคอมไพเลอร์มัน spill ตัวแปรสำคัญที่เราอยากเก็บไว้ในรีจิสเตอร์ ดึงการจัดการ stack frame ที่เราอยากย่อให้เล็กลงรอบการเรียก fallback function ขึ้นมา และรวมเส้นทางโค้ดที่เหมือนกันซึ่งเราอยากแยกไว้เพราะ branch prediction เข้าด้วยกัน อาจให้ความรู้สึกเหมือนเล่นเปียโนทั้งที่ใส่ถุงมือ
ในกรณีนี้ก็เกิด “การรวมเส้นทางโค้ดที่เหมือนกัน” นั้นพอดี และคอมไพเลอร์ที่ “มีบั๊ก” ก็รวมเส้นทางเดียวกันจนทำให้ประสิทธิภาพแย่ลง
คอมไพเลอร์ที่ “แก้แล้ว” ไม่ทำแบบนั้นอีก แต่การแก้นั้นก็ใกล้เคียงกับการปรับ heuristic ภายในของคอมไพเลอร์อยู่ดี ไม่มีอะไรรับประกันได้ว่าคอมไพเลอร์นี้หรือคอมไพเลอร์อื่น ๆ จะรักษา heuristic ในแบบที่เป็นประโยชน์กับเราไปตลอด
ในทางกลับกัน tail-call interpreter สามารถแสดง pattern ของ machine code ที่ต้องการไว้ในตัว interpreter เองได้ เมื่อใช้ attribute
musttail,noinline,preserve_noneร่วมกัน ก็สามารถจำกัดปัญหาให้ถูก heuristic ของ optimizer ชักจูงได้น้อยลงมากดังนั้นประโยชน์ของ tail-call interpreter จึงไม่ใช่แค่ประสิทธิภาพดีขึ้น 3~5% แต่ในบางคอมไพเลอร์อาจเป็น การปรับปรุงประสิทธิภาพที่เชื่อถือได้ มากกว่านั้น
เราจะปรับปรุง benchmark เพื่อป้องกันเหตุการณ์คล้ายกันได้ไหม?
[0] https://github.com/faster-cpython/benchmarking-public
การทำเบนช์มาร์ก ให้ถูกต้องนั้นยากอย่างบ้าคลั่งจริง ๆ มีปัจจัยที่หลอกคนได้มากเกินไป
เมื่อเร็ว ๆ นี้ผมคิดว่าพบวิธีทำให้อัลกอริทึมหนึ่งเร็วขึ้นราว 15% อย่างน้อยเบนช์มาร์กทั้งหมดก็บอกเช่นนั้น
แต่พอคัดลอกฟังก์ชันที่เร็วกว่าใส่เข้าไปใน test harness แล้วจริง ๆ ไม่ได้เรียกใช้ เรียกแค่เวอร์ชันเดิมที่ช้ากว่า ก็ยังเร็วขึ้น 15% อยู่ดี กลายเป็นว่าโค้ดที่ไม่ได้ถูกรันเลยทำให้โค้ดเดิมเร็วขึ้น
แน่นอนว่าเป็นปัญหาเรื่องการจัดวางโค้ดและหน่วยความจำ และมีบางอย่างถูกย้ายจนเข้ากับแคช CPU ได้ดีขึ้น
มันยากจริง ๆ ที่จะรู้ว่าความเร็วที่เพิ่มขึ้นนั้นมาจากโค้ดที่ “ดีขึ้น” จริง ๆ หรือแค่โชคดีได้ alignment ที่ดีกว่าที่ไหนสักแห่ง
Casey Muratori กำลังเขียนซีรีส์ที่น่าสนใจมากบน Substack เกี่ยวกับหัวข้อนี้
ขอชื่นชมผู้เขียนบทความที่ขุดลงไปจนเปิดเผยสถานการณ์จริงได้ tail-call interpreter ของ Python 3.14 ยังเป็นการปรับปรุงที่ดีอยู่ และการเพิ่มประสิทธิภาพไม่กี่เปอร์เซ็นต์ใน runtime ของภาษานั้นเป็นความสำเร็จที่ได้มาอย่างยากลำบาก
เพียงแต่มันไม่ใช่อาหารกลางวันฟรีแบบเวทมนตร์ 15%
สิ่งที่สำคัญกว่านั้นคือกรณีนี้แสดงให้เห็นอย่างดีถึงความสำคัญของความเข้มงวดในการทำเบนช์มาร์กและการทดสอบในหลายสภาพแวดล้อม อีกทั้งยังเผยบั๊กของคอมไพเลอร์ที่อาจเป็นประโยชน์กับทุกคนด้วย
เป็นการวิเคราะห์เชิงลึกชนิดที่ทำให้ต้องกลับไปตรวจสอบคำกล่าวอ้างเรื่องการเพิ่มประสิทธิภาพครั้งใหญ่ในครั้งต่อไปอีกครั้ง คำถามที่น่าคิดต่อคือ ในผลลัพธ์ “เร็วขึ้น X%” จำนวนมากที่มีอยู่ตอนนี้ มีสักเท่าไรที่จริง ๆ แล้วเป็น artefact ของเบนช์มาร์กหรือ regression ที่ยังไม่เป็นที่รู้จัก
ต่อไปเราควรทำอย่างไรเพื่อหลีกเลี่ยงกับดักแบบนี้ให้ดีขึ้น?
ไม่ได้ทำเบนช์มาร์กตัวคอมไพเลอร์เองหรือ? หรือเบนช์มาร์กเดิมของฝั่งคอมไพเลอร์หรือฝั่ง Python ไม่ได้ใช้คอมไพเลอร์ตัวนั้น?
นี่เป็นตัวอย่างที่ดีว่าคำกล่าวที่ว่า C “ใกล้เคียงกับเครื่อง” หรือเป็น “แอสเซมบลีแบบพกพาได้” นั้นไม่ตรงแค่ไหน ตัวเพิ่มประสิทธิภาพสมัยใหม่จะเปลี่ยนตรรกะอย่างกล้าหาญหากไม่มีผลที่สังเกตได้
ในบทความก็กล่าวไว้ว่า “clang-19 คอมไพล์ computed-goto interpreter ได้ ‘ถูกต้อง’ ในแง่ที่ไบนารีผลลัพธ์ให้ค่าที่คาดหวังทั้งหมด แต่ในขณะเดียวกัน output นั้นก็สวนทางกับเจตนาของการปรับให้เหมาะสมอย่างสิ้นเชิง ยิ่งไปกว่านั้น คอมไพเลอร์เวอร์ชันอื่น ๆ ยังนำ optimization ไปใช้กับ interpreter แบบ
switch()ที่ ‘ซื่อ ๆ’ และทำ optimization แบบเดียวกับที่เรา ‘ตั้งใจ’ จะทำโดยการเขียนซอร์สโค้ดใหม่พอดี”ใน C เราเชื่อได้ว่า
a += 1คือการเพิ่มค่าตัวเลข แต่ expression เดียวกันใน C++ อาจจัดสรรหน่วยความจำ คลี่ call stack หรือทำสิ่งที่ไม่รู้ได้ ในทำนองเดียวกันa = "a"ใน C เป็นเพียงการกำหนดค่า pointer แต่ใน C++ อาจเกิดการจัดสรรหน่วยความจำ ฯลฯคำว่า “C เป็นแอสเซมบลีแบบพกพาได้” ไม่ได้หมายความว่าแต่ละ statement จะถูกคอมไพล์โดยตรงเป็นภาษาเครื่องที่เทียบเท่ากัน
ไม่น่าแปลกใจที่คอมไพเลอร์ไปแตะโครงสร้างลูปจนทำให้ tail-call interpreter ทั้งหมดไม่ได้มีประสิทธิภาพเท่าที่ประกาศไว้
implementation ของ interpreter แบบระแวงสุด ๆ ถึงขั้นกลับไปเขียนแอสเซมบลีเองอีกครั้ง LuaJIT มีชื่อเสียงว่า implement ระบบแมโครเพื่อทำให้ loop assembly implementation ที่มีประสิทธิภาพสูงสามารถพกพาข้ามสถาปัตยกรรมได้ นั่นจึงเป็นเหตุผลที่การลองแตะของพวกนี้สนุก
เมื่อหลายปีก่อนผมเคยเขียนบทความและทำการทดสอบเกี่ยวกับวิธี implement loop ของ interpreter ที่นิยมใช้ไว้ด้วย:
https://github.com/vkazanov/bytecode-interpreters-post
branch predictor สมัยใหม่สามารถทำนาย indirect jump เดี่ยว ๆ ได้แทบแม่นยำ หากช่วงการรันยาวพอและพฤติกรรมของโค้ดที่ถูกตีความเองมีความเสถียร
มี paper ที่ศึกษาสิ่งนี้ทั้งบนฮาร์ดแวร์จริงและ branch predictor จำลองเฉพาะ: https://inria.hal.science/hal-01100647/document
การทดลองที่ทำในโปรเจกต์นี้ก็สนับสนุนข้อสรุปเดียวกันในเชิงเกร็ดหลักฐาน แม้ไม่ได้ใส่ไว้ในบทความ แต่ผมดู interpreter หลายตัวด้วย hardware CPU counters และ
perf statแล้ว branch misprediction ไม่ได้ปรากฏเป็นปัจจัยหลักการประเมินประสิทธิภาพของ build Python นั้นยากอย่างยิ่ง เพราะมี เทคนิคการ build ที่สามารถเพิ่มประสิทธิภาพได้มากเกินไป
เมื่อเร็ว ๆ นี้ฝั่ง astral ก็เจอปัญหาแบบนี้เช่นกัน โดยแสดงให้เห็นว่า build ของ conda-forge เร็วกว่าส่วนใหญ่อย่างเห็นได้ชัด:
https://github.com/astral-sh/python-build-standalone/pull/54...
อยากรู้ว่า tail-call interpreter จะทำงานอย่างไรเมื่อใช้ร่วมกับ optimization การ build อื่น ๆ ที่มีอยู่
ผู้เขียนลองชุดค่าผสมของคอมไพเลอร์และ optimization flags หลายแบบด้วย genetic algorithm
การอภิปรายที่เกี่ยวข้อง:
https://docs.python.org/3.14/whatsnew/3.14.html#whatsnew314-... --> https://news.ycombinator.com/item?id=42999672 (66 points | 25 days ago | 22 comments)
https://blog.reverberate.org/2025/02/10/tail-call-updates.ht... --> https://news.ycombinator.com/item?id=43076088 (124 points | 18 days ago | 92 comments)
เป็นบทความที่ดี มีรายละเอียดอย่างหนึ่งที่สะดุดตา
ในหนึ่งในบทความที่อ้างถึงคือ https://simonwillison.net/2025/Feb/13/python-3140a5/ เขียนไว้ว่า “3.14.0a5 เร็วกว่า 3.13 ใน benchmark 1.12 เท่า บน M2 MacBook Pro ของผมที่โอเวอร์โหลดอย่างหนัก”
ตรงนี้ค่อนข้างชวนสับสน หมายความว่าเขารัน benchmark ตอนที่คอมพิวเตอร์โอเวอร์โหลดจากโปรเซสอื่นอยู่หรือเปล่า? ถ้าอย่างนั้นผลลัพธ์ก็ไม่น่าเชื่อถือเลยไม่ใช่หรือ?
ผมคิดว่า benchmark แบบนี้ควรทำในสภาพแวดล้อมที่ควบคุมอย่างมากเพื่อกำจัดตัวแปรภายนอก
ฝั่ง CPython ดูได้ที่ https://github.com/faster-cpython/benchmarking-public
ที่นี่บางคนเรียก 10% ว่า “ใหญ่” และ 1% ว่า “ปกติ” แต่การ optimize อย่าง partial inlining ของ Fibonacci แบบ recursive สองชั้น สามารถลดทั้งปริมาณงานจริงและเวลาได้แบบเอ็กซ์โปเนนเชียล
สำหรับอาร์กิวเมนต์สองหลัก อาจเร็วขึ้นมากกว่า 10 เท่า หรือก็คือหลายพันเปอร์เซ็นต์ได้ พูดให้เคร่งครัดคือมันเป็นเอ็กซ์โปเนนเชียลต่อความต่างของความลึก recursion ไม่ใช่ขนาดปัญหา [1]
คอมไพเลอร์ C ก็อาจไวต่อ metric ของการ inline โค้ดมาก ๆ ทำให้การเพิ่มความเร็วมหาศาลนั้นจะเกิดขึ้นจริงหรือไม่ขึ้นอยู่กับรูปแบบโค้ดอย่างมาก
ดังนั้นส่วนหนึ่งของปัญหาคือ CPU มีความประณีตและซับซ้อนมากขึ้น แต่ในอีกด้านหนึ่ง คอมไพเลอร์ที่เกินกว่า
-O0หรือ-O1ก็ประณีตและซับซ้อนขึ้นเช่นกันบทความนี้ดีและควรอ่าน แต่ก็เป็นหนึ่งในตัวอย่างมากมายที่สิ่งซับซ้อนสองอย่างมีปฏิสัมพันธ์กันแล้วให้ผลลัพธ์ที่น่าประหลาดใจอย่างมาก เรื่องนี้จริงแม้นอกวงการคอมพิวเตอร์
ผู้คนมีแนวโน้มสูงที่จะทำให้เรื่องง่ายเกินไป ไม่ว่าบทเรียนนี้จะถูกย้ำมากี่ครั้งแล้วก็ตาม
เพิ่มเติมคือ ในบทความอย่างน้อยใช้ CPU สองตัวคือ Intel และ Apple M1 และคอมไพเลอร์สองตัวคือ gcc และ clang แต่ในสภาพแวดล้อม deployment จริง อาจมีเจเนอเรชันและ implementation ของ Intel, AMD, ARM รวมถึงคอมไพเลอร์อื่น ๆ อีกมากมาย ถือว่าเป็นการสุ่มตัวอย่างเพียงส่วนน้อยมากของความซับซ้อนทั้งหมด
หากจะทำให้เป็นวิทยาศาสตร์มากขึ้น โดยเฉพาะกับความต่างอย่าง “1.01 เท่า” การวัดเวลาควรมี error bars ในรูปแบบใดรูปแบบหนึ่ง อาจเป็นส่วนเบี่ยงเบนมาตรฐานของค่าเฉลี่ย หรือในกรณีแบบนี้ส่วนเบี่ยงเบนมาตรฐานของค่าต่ำสุดอาจดีกว่า [2]
เพื่อลด error ในการวัด อาจจำเป็นต้องใช้การกำหนด scheduling ให้ตรึง CPU core ใน OS ด้วย
[1] https://stackoverflow.com/questions/360748/computational-com...
[2] https://github.com/c-blake/bu/blob/main/doc/tim.md
ช่วงหลังได้ลอง benchmark Python 3.9 ถึง 3.13 ดู และจนถึง 3.11 ประสิทธิภาพก็ดีขึ้นเรื่อย ๆ
แต่ Python 3.12 และ 3.13 ช้ากว่า 3.11 ประมาณ 10%
ผมคิดว่า benchmark ที่ทำเองอาจยังดีไม่พอ แต่ก็ลอง deploy ไปยังบริการหลักอยู่ดี และ metric ที่เก็บมาก็แสดงการเปลี่ยนแปลงแบบเดียวกัน
มีใครเจอปัญหาเดียวกันไหม?
[0]: https://github.com/python/cpython/issues/123540