3 คะแนน โดย GN⁺ 2025-03-11 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • อินเทอร์พรีเตอร์แบบ 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

โครงสร้าง benchmark และตัวเลขสำคัญ

  • เปรียบเทียบบิลด์ CPython หลายแบบบนเซิร์ฟเวอร์ Intel และ Apple M1 MacBook Air
    • เซิร์ฟเวอร์ Intel คือ Raptor Lake i5-13500 ที่รันอยู่บน Hetzner
    • ทุกบิลด์ใช้ LTO และ PGO
    • ใช้ คอนฟิก nix เพื่อให้บิลด์ซ้ำได้
  • รายการที่นำมาเปรียบเทียบมีดังนี้
    • clang18: Clang 18.1.8, computed goto
    • gcc: GCC 14.2.1, computed goto, เฉพาะ Intel
    • clang19: Clang 19.1.7, computed goto
    • clang19.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 เท่า
  • อินเทอร์พรีเตอร์แบบ 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
  • การเปรียบเทียบจำนวน indirect jump ก็เผยให้เห็นความต่างนี้
    • clang18: 332 จุด
    • clang18.nocg: 306 จุด
    • clang19.nocg: 3 จุด
    • clang19: 3 จุด
  • ใน Clang สมัยใหม่ อินเทอร์พรีเตอร์แบบ computed goto ทั้งหมดอาจเป็นความซับซ้อนที่ไม่จำเป็น
    • เพราะคอมไพเลอร์สามารถทำ transformation เดียวกันกับโค้ดที่ใช้ switch ได้ด้วย
    • ในทางกลับกัน computed goto เองก็ไม่เพียงพอที่จะรับประกัน 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 หลากหลายแบบ
  • การใช้ nix ทำให้สามารถรักษาเวอร์ชันคู่ขนานหลายชุดในแบบที่ทำซ้ำได้และแยกกันชัดเจน
    • สามารถมั่นใจได้ว่าบิลด์หนึ่ง ๆ มาจากคอมไพเลอร์และ flag ใด
    • นิยาม build matrix ก็จัดการได้ด้วย abstraction สั้น ๆ
  • การบิลด์ LLVM แบบ custom ที่ใส่ patch แก้บั๊ก แล้วใช้คอมไพเลอร์นั้นบิลด์ Python ต่อ ก็ทำได้ด้วยโค้ดประมาณ 10 บรรทัด
  • ยังมีข้อเสียอยู่
    • nix มีบางส่วนที่ต่างจากวิธีใช้ซอฟต์แวร์ทั่วไป จึงตัดความเป็นไปได้ทั้งหมดได้ยากว่าความต่างเหล่านี้อาจมีผลต่อ benchmark หรือข้อสรุป
    • ตัวอย่างเช่น โดยค่าเริ่มต้น nix จะบิลด์โปรเจกต์ด้วย hardening flag บางอย่าง และช่วงแรกพบว่า flag นี้ ส่งผลอย่างไม่สมดุล ต่ออินเทอร์พรีเตอร์แบบ tail call
  • Nix มีความสามารถในการขยายและปรับแต่งสูง แต่การหาวิธีปรับแต่งเฉพาะทางต้องลองผิดลองถูกจำนวนมากและต้องค้นซอร์สของ nixpkgs

1 ความคิดเห็น

 
GN⁺ 2025-03-11
ความคิดเห็นจาก Hacker News
  • ผมเป็นผู้เขียน PR ที่ใส่ tail-call interpreter เข้าไปใน CPython
    ก่อนอื่นต้องขอบคุณ Nelson ที่ใช้เวลาเกือบหนึ่งเดือนในการหาต้นตอของปัญหานี้
    ทั้งผม และน่าจะรวมถึงทีม CPython ด้วย ไม่ได้คาดคิดเลยว่าคอมไพเลอร์ที่ใช้เป็น baseline จะมีบั๊กแบบนั้น ผมทำพลาดครั้งใหญ่ จึงอายและเสียใจมาก
    ผมได้โพสต์คำขอโทษไว้ด้วย: https://fidget-spinner.github.io/posts/apology-tail-call.htm...

    • พอเห็นคำว่า “ทำพลาดครั้งใหญ่ จึงอายและเสียใจมาก” ผมนึกว่าทำให้ประสิทธิภาพของ CPython แย่ลง แต่จริง ๆ แล้วไม่ใช่แบบนั้นเลย
      มีการประกาศว่า ประสิทธิภาพดีขึ้น 10~15% แต่กับคอมไพเลอร์ที่ไม่มีบั๊ก ตัวเลขน่าจะใกล้ 1~5% มากกว่า และตัวเลขเดิมก็ไม่ได้ผิดไปหมด เพียงแต่ถูกต้องเฉพาะในเงื่อนไขบางอย่างเท่านั้น
      คุณได้สร้างการปรับปรุง วัดผล และให้ PR ผ่านการรีวิวแล้ว ก็ถือว่าทำสิ่งที่ควรทำแล้วพอดี ปัญหาเป็นเพราะเวอร์ชันของ clang ที่ใช้วัดผลทำให้ตัวเลขชวนเข้าใจผิด ซึ่งดูเป็นความผิดพลาดที่สมเหตุสมผลและใคร ๆ ก็อาจเจอได้
      ถึงอย่างนั้นก็ยังนำมาซึ่งการปรับปรุงประสิทธิภาพที่มีความหมาย และยังค้นพบ regression ของคอมไพเลอร์ด้วย เมื่อเทียบกันแล้วตัวเลขที่ผิดดูเป็นเรื่องเล็ก ผมก็ไม่ค่อยแน่ใจด้วยว่ามีใครได้รับความเสียหายจริง ๆ จากเรื่องนี้ และไม่น่าถึงขั้นต้องขอโทษ
    • อนึ่ง หลังจากเขียนบล็อกโพสต์นั้นแล้ว แพตช์แก้ไขถูก merge แล้ว ;)
      ถ้า 3~5% ยังรักษาไว้ได้ในระบบเก่าแก่แบบ Python interpreter แค่นั้นก็ถือเป็นความสำเร็จใหญ่และควรภูมิใจได้เต็มที่
      พอผ่านมาราว 30 ปี ผมก็เริ่มสงสัยไว้ก่อนกับการปรับปรุงประสิทธิภาพที่มีความหมายในระบบที่อยู่มานาน โดยเฉพาะการปรับปรุงที่เกิน 1%
      การปรับปรุงจริง ๆ ก็มีอยู่บ้าง แต่ไม่ค่อยพบ และบ่อยครั้งเป็นแค่การย้ายเวลาไปไว้ที่อื่น จน benchmark จับไม่ได้ อีกอย่าง benchmark มักทำในสภาพแวดล้อมที่ควบคุมเพื่อแยกผลออกมา แต่ซอฟต์แวร์จริงรันอยู่บน VM หรือเดสก์ท็อปร่วมกับสารพัดอย่างอื่น
      ผมเห็นมาหลายครั้งแล้วว่า optimization ที่ดูชัดเจนและใหญ่ในสภาพแวดล้อมที่แยกออกมา พอเข้า production แล้วกลับหายไปหรือกลายเป็นค่าลบ
      CPython ยากยิ่งกว่านั้นเพราะต้องรองรับหลายสภาพแวดล้อม และไม่มีเป้าหมาย production เดี่ยว ๆ ที่จะพูดได้ว่า “ถ้าใน production ไม่เร็วขึ้น ก็ไม่ได้เร็วขึ้นจริง” การพยายามปรับปรุงประสิทธิภาพในโลกแบบนั้นยากมากจริง ๆ
      สุดท้ายแล้ว การจูนและวัดประสิทธิภาพ เป็นเรื่องยากมาก และสิ่งเดียวที่ควรรู้สึกขอโทษก็คงเป็นแค่การได้เรียนรู้ข้อเท็จจริงนั้น
      อยากให้ไม่กลัวการผิดพลาด เพราะยังไงทุกคนก็ผิดพลาดได้ ทำแบบตอนนี้ก็พอ คือพูดว่า “ดูเหมือนนี่เป็นสิ่งที่เราทำพัง” แล้วหาวิธีจัดการ รวมถึงวิธีหลีกเลี่ยงในอนาคต
      [1] ไม่ใช่แค่เรื่องประสิทธิภาพ แต่ในกระบวนการของคนก็พบได้บ่อย เช่น ทีมเครื่องมือ code review อาจบอกว่า “เราลดเวลา code review ได้ 15% ทำให้ workflow ของทุกคนเร็วขึ้น” แต่จริง ๆ แล้วอาจไปสร้างงานเพิ่มในส่วนอื่นของระบบ ทำให้ flow โดยรวมไม่ได้เร็วขึ้น และเพียงย้าย 15% ไปยังจุดที่ไม่ได้วัด
    • ผมมองว่าแรงจูงใจหลักอย่างหนึ่งของการออกแบบ tail-call interpreter คือการทำให้เปราะบางต่อ ความแปรปรวนของ optimizer น้อยลง บทความต้นฉบับที่พูดถึงเทคนิคนี้(https://blog.reverberate.org/2021/04/21/musttail-efficient-i...) ก็อธิบายไว้แบบนั้น
      ตามทฤษฎีแล้ว ถ้ามี 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% แต่ในบางคอมไพเลอร์อาจเป็น การปรับปรุงประสิทธิภาพที่เชื่อถือได้ มากกว่านั้น
    • ขอเคารพท่าทีที่สามารถพูดได้ว่า “ขอโทษ ผมทำพลาด” ผมเกลียดวัฒนธรรม แกล้งทำเป็นยืนหยัดแล้วทำเหมือนสำเร็จ ที่ดูเหมือนเป็นมาตรฐานในยุคนี้จริง ๆ
    • สงสัยว่าเหตุใด regression ของประสิทธิภาพ baseline จึงไม่ปรากฏในหน้า benchmark ของ faster-cpython [0] หรือว่ามันปรากฏแล้ว
      เราจะปรับปรุง benchmark เพื่อป้องกันเหตุการณ์คล้ายกันได้ไหม?
      [0] https://github.com/faster-cpython/benchmarking-public
  • การทำเบนช์มาร์ก ให้ถูกต้องนั้นยากอย่างบ้าคลั่งจริง ๆ มีปัจจัยที่หลอกคนได้มากเกินไป
    เมื่อเร็ว ๆ นี้ผมคิดว่าพบวิธีทำให้อัลกอริทึมหนึ่งเร็วขึ้นราว 15% อย่างน้อยเบนช์มาร์กทั้งหมดก็บอกเช่นนั้น
    แต่พอคัดลอกฟังก์ชันที่เร็วกว่าใส่เข้าไปใน test harness แล้วจริง ๆ ไม่ได้เรียกใช้ เรียกแค่เวอร์ชันเดิมที่ช้ากว่า ก็ยังเร็วขึ้น 15% อยู่ดี กลายเป็นว่าโค้ดที่ไม่ได้ถูกรันเลยทำให้โค้ดเดิมเร็วขึ้น
    แน่นอนว่าเป็นปัญหาเรื่องการจัดวางโค้ดและหน่วยความจำ และมีบางอย่างถูกย้ายจนเข้ากับแคช CPU ได้ดีขึ้น
    มันยากจริง ๆ ที่จะรู้ว่าความเร็วที่เพิ่มขึ้นนั้นมาจากโค้ดที่ “ดีขึ้น” จริง ๆ หรือแค่โชคดีได้ alignment ที่ดีกว่าที่ไหนสักแห่ง
    Casey Muratori กำลังเขียนซีรีส์ที่น่าสนใจมากบน Substack เกี่ยวกับหัวข้อนี้

    • น่าทึ่งที่ ลอตเตอรี่ของลิงเกอร์ แบบนั้นทำให้ดีขึ้นได้ถึง 15% อยากรู้ว่าในกรณีแบบไหนถึงเกิดการเพิ่มขึ้นมากขนาดนั้น มันหายากหรือไม่ และสุดท้ายตัดสินได้อย่างไร
    • จำได้เลือน ๆ ว่าเคยมีโปรเจกต์เบนช์มาร์กที่ตั้งใจสุ่มการตัดสินใจของคอมไพเลอร์ เพื่อประเมินได้เสถียรกว่าว่าโค้ดทำงานได้ดีจริงแค่ไหน และไม่ถูกผลจากการชนะหรือแพ้ลอตเตอรี่ของลิงเกอร์ชี้นำมากเกินไป
    • Aleksey Shipilёv ซึ่งทำงานเป็น “วิศวกรประสิทธิภาพ” ของ Java มายาวนาน ได้เขียนบทความและบรรยายไว้มากมายเกี่ยวกับความยากของการทำเบนช์มาร์ก ขอแนะนำบล็อกหรือสไลด์บรรยายของเขาอย่างยิ่ง
  • ขอชื่นชมผู้เขียนบทความที่ขุดลงไปจนเปิดเผยสถานการณ์จริงได้ tail-call interpreter ของ Python 3.14 ยังเป็นการปรับปรุงที่ดีอยู่ และการเพิ่มประสิทธิภาพไม่กี่เปอร์เซ็นต์ใน runtime ของภาษานั้นเป็นความสำเร็จที่ได้มาอย่างยากลำบาก
    เพียงแต่มันไม่ใช่อาหารกลางวันฟรีแบบเวทมนตร์ 15%
    สิ่งที่สำคัญกว่านั้นคือกรณีนี้แสดงให้เห็นอย่างดีถึงความสำคัญของความเข้มงวดในการทำเบนช์มาร์กและการทดสอบในหลายสภาพแวดล้อม อีกทั้งยังเผยบั๊กของคอมไพเลอร์ที่อาจเป็นประโยชน์กับทุกคนด้วย
    เป็นการวิเคราะห์เชิงลึกชนิดที่ทำให้ต้องกลับไปตรวจสอบคำกล่าวอ้างเรื่องการเพิ่มประสิทธิภาพครั้งใหญ่ในครั้งต่อไปอีกครั้ง คำถามที่น่าคิดต่อคือ ในผลลัพธ์ “เร็วขึ้น X%” จำนวนมากที่มีอยู่ตอนนี้ มีสักเท่าไรที่จริง ๆ แล้วเป็น artefact ของเบนช์มาร์กหรือ regression ที่ยังไม่เป็นที่รู้จัก
    ต่อไปเราควรทำอย่างไรเพื่อหลีกเลี่ยงกับดักแบบนี้ให้ดีขึ้น?

    • คำถามที่ใหญ่กว่าคือทำไม ประสิทธิภาพ Python ลดลง 10% ตอนที่ฟีเจอร์คอมไพเลอร์ที่มีข้อบกพร่องถูกใส่เข้าไปจึงไม่ถูกตรวจพบ
      ไม่ได้ทำเบนช์มาร์กตัวคอมไพเลอร์เองหรือ? หรือเบนช์มาร์กเดิมของฝั่งคอมไพเลอร์หรือฝั่ง Python ไม่ได้ใช้คอมไพเลอร์ตัวนั้น?
  • นี่เป็นตัวอย่างที่ดีว่าคำกล่าวที่ว่า C “ใกล้เคียงกับเครื่อง” หรือเป็น “แอสเซมบลีแบบพกพาได้” นั้นไม่ตรงแค่ไหน ตัวเพิ่มประสิทธิภาพสมัยใหม่จะเปลี่ยนตรรกะอย่างกล้าหาญหากไม่มีผลที่สังเกตได้
    ในบทความก็กล่าวไว้ว่า “clang-19 คอมไพล์ computed-goto interpreter ได้ ‘ถูกต้อง’ ในแง่ที่ไบนารีผลลัพธ์ให้ค่าที่คาดหวังทั้งหมด แต่ในขณะเดียวกัน output นั้นก็สวนทางกับเจตนาของการปรับให้เหมาะสมอย่างสิ้นเชิง ยิ่งไปกว่านั้น คอมไพเลอร์เวอร์ชันอื่น ๆ ยังนำ optimization ไปใช้กับ interpreter แบบ switch() ที่ ‘ซื่อ ๆ’ และทำ optimization แบบเดียวกับที่เรา ‘ตั้งใจ’ จะทำโดยการเขียนซอร์สโค้ดใหม่พอดี”

    • จากมุมมองของภาษาโปรแกรมระบบอื่น ๆ ในยุค 80–90 C ยังถือว่าใกล้เคียงกับ แอสเซมบลีแบบพกพาได้ อยู่มาก
      ใน C เราเชื่อได้ว่า a += 1 คือการเพิ่มค่าตัวเลข แต่ expression เดียวกันใน C++ อาจจัดสรรหน่วยความจำ คลี่ call stack หรือทำสิ่งที่ไม่รู้ได้ ในทำนองเดียวกัน a = "a" ใน C เป็นเพียงการกำหนดค่า pointer แต่ใน C++ อาจเกิดการจัดสรรหน่วยความจำ ฯลฯ
      คำว่า “C เป็นแอสเซมบลีแบบพกพาได้” ไม่ได้หมายความว่าแต่ละ statement จะถูกคอมไพล์โดยตรงเป็นภาษาเครื่องที่เทียบเท่ากัน
    • “ไม่มีผลที่สังเกตได้” กลายเป็นบล็อกโพสต์ยาว 10,000 คำไปแล้ว
  • ไม่น่าแปลกใจที่คอมไพเลอร์ไปแตะโครงสร้างลูปจนทำให้ tail-call interpreter ทั้งหมดไม่ได้มีประสิทธิภาพเท่าที่ประกาศไว้

    1. สถาปัตยกรรม CPU และเวอร์ชันมีความสำคัญมาก 95% ของปัญหาคือการจัดวางโค้ด dispatch คำสั่งให้ branch predictor ทำงานได้เหมาะที่สุด แต่ C ไม่ใช่ภาษาที่ถูกสร้างมาเพื่อรองรับเรื่องนี้ตั้งแต่แรก
    2. C abstract machine ก็ยังไม่ low-level พอที่จะสื่อเจตนาได้อย่างถูกต้อง การ implement แบบใดก็ตามจะไวต่อคุณลักษณะของคอมไพเลอร์เฉพาะตัวและเวอร์ชันเฉพาะมากเกินไป
      implementation ของ interpreter แบบระแวงสุด ๆ ถึงขั้นกลับไปเขียนแอสเซมบลีเองอีกครั้ง LuaJIT มีชื่อเสียงว่า implement ระบบแมโครเพื่อทำให้ loop assembly implementation ที่มีประสิทธิภาพสูงสามารถพกพาข้ามสถาปัตยกรรมได้ นั่นจึงเป็นเหตุผลที่การลองแตะของพวกนี้สนุก
      เมื่อหลายปีก่อนผมเคยเขียนบทความและทำการทดสอบเกี่ยวกับวิธี implement loop ของ interpreter ที่นิยมใช้ไว้ด้วย:
      https://github.com/vkazanov/bytecode-interpreters-post
    • ในฐานะผู้เขียน ผมได้เรียนรู้ระหว่างเขียนบทความนี้ว่าคำกล่าว “95% ของปัญหาคือการจัดวางโค้ด dispatch คำสั่งให้ branch predictor ทำงานได้เหมาะที่สุด” นั้นไม่จริงอีกต่อไปแล้ว
      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 อื่น ๆ ที่มีอยู่

  • การอภิปรายที่เกี่ยวข้อง:
    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 แบบนี้ควรทำในสภาพแวดล้อมที่ควบคุมอย่างมากเพื่อกำจัดตัวแปรภายนอก

    • Simon Willison เป็นคนที่ยอดเยี่ยม แต่เขาไม่ใช่นักพัฒนาแกนหลักของ Python และ benchmark แบบเฉพาะกิจของเขาก็ไม่ใช่สิ่งที่ทีมแกนหลักของ CPython ใช้
      ฝั่ง 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 ที่เก็บมาก็แสดงการเปลี่ยนแปลงแบบเดียวกัน
    มีใครเจอปัญหาเดียวกันไหม?

    • ใช่ เจอ performance regression ของ loop ใน 3.12 และ 3.13 [0]
      [0]: https://github.com/python/cpython/issues/123540
    • แอป FastAPI ก็ช้าลงค่อนข้างมากใน 3.12 และ 3.13 เลยยังใช้ 3.11 อยู่