เขียนโค้ด C ใหม่เป็น Ruby เพื่อเพิ่มความเร็วให้ Ruby
(jpcamara.com)- ในเบนช์มาร์กภาษาเล็ก ๆ CRuby อยู่ในอันดับ ที่สามจากท้าย แต่แสดงให้เห็นว่าคอขวดไม่ได้อยู่ที่ทั้งภาษา แต่อยู่ที่การทำงานของลูปที่ YJIT ไม่สามารถมองเข้าไปข้างในได้
- Ruby 3.3.6 บน M3 MacBook Pro ใช้เวลา Fibonacci 12.17 วินาที, Loops 28.80 วินาที ขณะที่ node.js ใช้เวลาระดับ 1 วินาทีทั้งสองกรณี และบน M2 MacBook Air Ruby ช้ากว่าเดิมที่ 16.33 วินาทีและ 33.43 วินาทีตามลำดับ
- แค่เปิด
ruby --yjitFibonacci ก็ลดลงอย่างมากจาก 16.88 วินาทีเหลือ 2.06 วินาที แต่ Loops ลดจาก 33.43 วินาทีเหลือเพียง 25.57 วินาที ทำให้ Range#each ยังคงเป็นคอขวด - เมธอดที่ย้าย implementation จาก C มาฝั่ง Ruby เช่น Integer#times ใน Ruby 3.3 และ Array#each ใน Ruby 3.4 กลายเป็นเป้าหมายให้ YJIT optimize ได้ ทำให้เบนช์มาร์กการวนซ้ำดีขึ้นมาอยู่แถว 13~14 วินาที
- CRuby สามารถใช้ hook
with_yjitเพื่อให้ใช้ implementation แบบ Ruby เมื่อเปิด YJIT และยังคงใช้ implementation แบบ C เมื่อปิด YJIT จึงเริ่มเกิดแนวทางเปลี่ยนไลบรารีหลักให้เป็น โค้ด Ruby ที่เป็นมิตรกับ YJIT
ตำแหน่งของ Ruby ที่ปรากฏในเบนช์มาร์ก
- language comparison repo ที่เพิ่งถูกแชร์เมื่อเร็ว ๆ นี้ เป็น repository ที่ร่วมกันสร้างเบนช์มาร์กขนาดเล็กของหลายภาษา
- ผลลัพธ์ของ CRuby ใน repository นี้อยู่ ที่สามจากท้าย โดยมีเพียง R และ Python ที่ช้ากว่า Ruby
- เบนช์มาร์กดูสองแกนหลัก
- Loops: เน้นประสิทธิภาพของลูป เงื่อนไข และการคำนวณพื้นฐาน
- Fibonacci: เผย overhead ของการเรียกฟังก์ชันและต้นทุนของ recursion
- ตัวอย่าง Loops ใช้ลูปซ้อนกันรวม 10,000 × 100,000 รอบ หรือเท่ากับ วนซ้ำ 1 พันล้านครั้ง
- ตัวอย่าง Fibonacci ไม่ได้ใช้ implementation ที่ optimize แล้ว แต่ใช้ naive Fibonacci ที่จงใจให้เรียบง่าย
ค่าที่วัดได้ช่วงแรกและผลของการใช้ YJIT
- บน M3 MacBook Pro, Ruby 3.3.6 อยู่ในระดับต่อไปนี้
- Fibonacci: 12.17 วินาที
- Loops: 28.80 วินาที
- node.js: ทั้งสองตัวอย่างใช้เวลามากกว่า 1 วินาทีเล็กน้อย
- บน M2 MacBook Air เบนช์มาร์กเดียวกันออกมาช้ากว่า
- Ruby Fibonacci: 16.33 วินาที
- Ruby Loops: 33.43 วินาที
- node.js Fibonacci: 1.36 วินาที
- node.js Loops: 2.07 วินาที
- คำสั่งรันใน repository เดิมเป็นรูปแบบ
ruby ./code.rb 40โดยไม่มี YJIT - เมื่อรันด้วย
ruby --yjit ./code.rb 40ผลลัพธ์เปลี่ยนไป- Fibonacci: 2.06 วินาที
- Loops: 25.57 วินาที
- YJIT ให้ผลอย่างมากกับ Fibonacci แต่ใน Loops ปรับปรุงได้จำกัด
เหตุผลที่ Range#each ไม่เอื้อต่อ YJIT
- ลูปหลักในโค้ด Loops อยู่ในรูป Range#each เช่น
(0...10_000).eachและ(0...100_000).each - ใน Ruby 3.4,
Range#eachยังคง เขียนด้วย C range.cของ CRuby เชื่อมคลาสRangeและเมธอดeachเข้ากับฟังก์ชัน C ชื่อrange_eachrange_eachแตกแขนงไปหลาย path เพื่อรองรับช่วงหลายรูปแบบ(0...).each(0...100).each("a"..."z").each
- ตัวฟังก์ชัน C อาจเร็วได้ แต่ YJIT ไม่สามารถมองเข้าไปข้างในได้
- การ optimize จะหยุดอยู่ที่จุดเรียกฟังก์ชัน แล้วค่อยทำต่อหลังฟังก์ชัน C return กลับมา
- YJIT สามารถสร้าง optimization ที่เจาะจงกับ execution path ที่ถูกใช้งานหนักได้ แต่ implementation แบบ C จำกัดข้อได้เปรียบนี้
การเปลี่ยนไปใช้ Integer#times ส่งผลอย่างไร
- ใน Ruby 3.3,
Integer#timesถูก เปลี่ยนจากฟังก์ชัน C เป็นเมธอด Ruby - โครงสร้างหลักเป็นลูป Ruby ง่าย ๆ ที่ประกอบด้วย
while i < self,yield i,i = i.succ - เพราะเขียนเป็นโค้ด Ruby YJIT จึงวิเคราะห์และ optimize ภายในได้
- เมื่อเปลี่ยนลูป Range เป็น
10_000.timesและ100_000.timesเวลา Loops ลดลงมากRange#each: 25.57 วินาทีInteger#times: 13.66 วินาที
- ในสภาพแวดล้อมวัดผลอื่น
Integer#timesลดลงถึง 9 วินาที และใน Ruby 3.4 ก็มีผลลัพธ์ 8 วินาทีด้วย
Integer#succ และการ optimize ระดับ VM
- implementation ของ
Integer#timesใช้i.succสำหรับการเพิ่มค่า แทนi += 1 Integer#succเป็นเมธอดที่คืนค่าจำนวนเต็มถัดไป- ใน bytecode ของ Ruby VM,
i.succถูกแทนด้วยขั้นตอนเดียวคือopt_succ - ขณะที่
i += 1แยกเป็นสองขั้นตอนputobject_INT2FIX_1_: นำจำนวนเต็ม 1 ขึ้นไปวางบน VM stackopt_plus: ทำ operation+
- ในโปรแกรม Ruby ทั่วไปแทบไม่ต้องใส่ใจเรื่องนี้ แต่ในระดับ JIT และ VM เมื่อวนซ้ำหลักล้านถึงหลักพันล้านครั้ง ความต่างหนึ่งขั้นตอนก็ส่งผลต่อ performance ได้
Array#each ก็ย้ายเป็น implementation แบบ Ruby ใน Ruby 3.4
- ใน Ruby 3.4,
Array#eachก็ถูกเปลี่ยนจาก C ไปเป็น implementation ฝั่ง Ruby - ความพยายามแรกเป็นโค้ด Ruby แบบง่าย ๆ แต่มี race condition ที่เกี่ยวข้องกับภายในของ CRuby
- implementation สุดท้ายใช้
Primitiveภายในโค้ด RubyPrimitive.attr! :inline_block, :c_tracePrimitive.cexpr!ary_fetch_next
- แม้จะไม่ใช่ Ruby บริสุทธิ์อย่างสมบูรณ์ แต่เป็นรูปแบบที่ผสมการประเมินโค้ด C กับโครงสร้าง Ruby แต่ YJIT ก็ยัง optimize ได้มาก
- ถ้าสร้าง array ไว้ล่วงหน้าแล้ววนซ้ำด้วย
Array#eachจะได้ performance ใกล้เคียงกับInteger#timesRange#each: 25.57 วินาทีInteger#times: 13.66 วินาทีArray#each: 13.96 วินาที
การวัดด้วย Ruby Microbench
- repository แยกชื่อ ruby_microbench เปรียบเทียบตัวอย่างเดิมกับรูปแบบลูปหลายแบบของ Ruby
- ผลลัพธ์บน Ruby 3.4 เมื่อเปิด YJIT มีดังนี้
- Fibonacci: 2.19 วินาที
array#each: 14.02 วินาทีrange#each: 26.61 วินาทีtimes: 13.12 วินาทีfor: 14.91 วินาทีwhile: 37.10 วินาทีloop do: 13.95 วินาที
- เมื่อปิด YJIT ใน Ruby 3.4 ส่วนใหญ่จะช้าลง
- Fibonacci: 16.49 วินาที
array#each: 34.29 วินาทีrange#each: 33.88 วินาทีtimes: 33.18 วินาทีfor: 36.32 วินาทีwhile: 37.14 วินาทีloop do: 50.65 วินาที
- ตัวอย่าง
whileช้ากว่าที่คาด และอาจเป็นปัญหาวิธี implementation for inกับarray#eachแทบจะเหมือนกันในระดับ bytecode ของ Ruby VM performance จึงใกล้เคียงกันด้วย- โดยทั่วไป
for inใกล้เคียงกับ syntax sugar ที่ถูก VM แปลงเป็นการเรียก#each
- โดยทั่วไป
เปรียบเทียบกับ implementation อื่นของ Ruby
- เบนช์มาร์กเดียวกันถูกรันบน implementation อื่นของ Ruby ด้วย
- ผลลัพธ์บางส่วนมีดังนี้
- TruffleRuby 24.1
- Fibonacci: 0.92 วินาที
array#each: 0.97 วินาทีrange#each: 0.92 วินาทีtimes: 2.39 วินาทีfor: 2.06 วินาทีwhile: 3.90 วินาทีloop do: 0.77 วินาที
- MRuby 3.3
- Fibonacci: 28.83 วินาที
array#each: 144.65 วินาทีrange#each: 126.40 วินาทีtimes: 128.22 วินาที
- Artichoke
- Fibonacci: 19.71 วินาที
array#each: 236.10 วินาทีrange#each: 214.55 วินาทีtimes: 214.51 วินาที
- TruffleRuby 24.1
การทดลอง monkey patch Range#each ให้เป็น Ruby
- เมื่อ monkey patch
Range#eachเป็น implementation แบบ Ruby ง่าย ๆ performance ดีขึ้นอย่างมาก - implementation เป็นรูปแบบง่าย ๆ ที่ใช้
begin,end,loop,yield,i.succ - ค่าที่วัดได้มีดังนี้
- implementation C ของ
Range#each: 25.57 วินาที - implementation Ruby ของ
Range#each: 16.64 วินาที
- implementation C ของ
- implementation นี้เป็น เวอร์ชันที่ทำให้ง่ายเกินไป และไม่ได้รองรับทุกกรณีของ
Range - ถึงอย่างนั้น การย้ายจาก C มาเป็น Ruby ก็ทำให้ YJIT optimize ได้ และเปิดทางให้ performance ดีขึ้นในแบบที่ยากหรือเป็นไปไม่ได้ที่จะทำซ้ำด้วยโค้ด C ทั่วไป
ไลบรารีมาตรฐานของ YJIT และ with_yjit
- Ruby Outperforms C ของ Aaron Patterson เป็นกรณีที่เขียน C extension สำหรับ parsing GraphQL ใหม่ด้วย Ruby และด้วยการ optimize ของ YJIT โค้ด Ruby จึงเร็วก่า C
- ทีม YJIT หลักของ CRuby กำลังนำแนวทางลบโค้ด C ออกจากฟีเจอร์หลักบางส่วน หรือใช้ implementation แบบ Ruby เฉพาะเมื่อเปิด YJIT
- บล็อก
with_yjitจะใช้ implementation แบบ Ruby นั้นเฉพาะเมื่อ YJIT เปิดใช้งาน- ถ้า YJIT ปิดอยู่ implementation แบบ C จะยังทำงานต่อไป
- ถ้า YJIT เปิดอยู่ จะใช้เวอร์ชัน Ruby ที่ YJIT optimize ได้
- ตั้งแต่ Ruby 3.3 เป็นต้นมา YJIT สามารถ initialize แบบ lazy ได้ และโค้ด
with_yjitจะปรับใช้เวอร์ชันเมธอดที่เหมาะสมในจังหวะที่ YJIT ถูกเปิดใช้งาน with_yjitเป็น hook ของ YJIT และหลังถูกเรียกแล้วจะถูกลบออกจาก runtime ด้วยundef :with_yjit
วิธีดู machine code ที่ YJIT สร้าง
- ถ้า build CRuby ด้วย option
--enable-yjit=devจะสามารถดู disassembly ของ machine code ที่ YJIT สร้างได้ - ตัวอย่างการ build มีดังนี้
./configure --enable-yjit=devmake install
- ตอนรันให้ใช้ flag
--yjit-dump-disasm./ruby --yjit --yjit-dump-disasm test.rb 40
i.succของInteger#timesปรากฏเป็นopt_succใน bytecode ของ VM- implementation ของ YJIT ใน Rust ทำสิ่งต่อไปนี้กับ
opt_succ- guard ว่า receiver เป็น Fixnum หรือไม่
- ถ้าไม่ใช่ Fixnum จะออกไปยัง execution path อื่น
- ถ้าเป็น Fixnum เนื่องจาก representation ภายในที่มี tag การบวก 1 จึงต้องบวก 2 จริง ๆ
- ถ้าเกิด overflow จะออกไปยัง path อื่น
- ตัวอย่างนี้แสดงให้เห็นว่า JIT optimization ทำงานข้ามหลายชั้น ตั้งแต่โค้ด Ruby, โค้ด C, VM bytecode, implementation ใน Rust ไปจนถึง machine code
ทิศทางของการ optimize CRuby
- งาน implementation ของ Ruby ส่วนใหญ่เกิดขึ้นในภาษาที่ระดับต่ำกว่า Ruby โดยใน CRuby ใช้ C เป็นหลักและมี Rust บางส่วน
- ชั้นอย่าง YJIT สร้างความเป็นไปได้ที่จะย้ายฟีเจอร์ของภาษาให้มาอยู่ในโค้ด Ruby ธรรมดามากขึ้น
- หากฟีเจอร์หลักถูกเขียนด้วย Ruby มากขึ้น นักพัฒนา Ruby ก็อาจมีส่วนร่วมกับ CRuby ได้ง่ายขึ้น
- โครงสร้างแบบ Java ที่ภาษาส่วนใหญ่ถูกเขียนด้วยตัวภาษาเองบน core ระดับต่ำขนาดเล็ก ถูกกล่าวถึงว่าเป็นหนึ่งในอนาคตที่เป็นไปได้ของ CRuby
- ในแนวทางปัจจุบัน สิ่งที่ยังสำคัญต่อไปคือการนำ implementation แบบ Ruby ที่ YJIT optimize ได้เข้าสู่ path หลัก แทนที่จะคง implementation แบบ C ไว้ตามเดิม
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
ตัวอย่างลูปดูเหมือนเป็นเบนช์มาร์กแปลก ๆ ที่รัน ลูปซ้อนกัน 1 พันล้านครั้ง และถ้าปรับแต่งด้วยมือ เวลารันมากกว่า 99% น่าจะไปกองอยู่ช่วงต้น
ถ้าทำ liveness analysis (การวิเคราะห์ช่วงที่ค่ายังถูกใช้งาน) แยกตามองค์ประกอบของอาร์เรย์ ดูเหมือนจะตัดลูปชั้นนอกทั้งหมดออกได้ เลยสงสัยว่ามีคอมไพเลอร์ที่ทำการวิเคราะห์แบบนี้จริง ๆ ไหม
แม้
uจะไม่รู้ค่าตอนคอมไพล์ ลูปชั้นในก็น่าจะเปลี่ยนเป็นคำสั่งไม่กี่คำสั่งได้เหมือนกัน และนี่ดูเป็นการปรับแต่งแบบมาตรฐานกว่าที่ฝั่งอย่างclangอาจทำได้ในไม่ช้ามีข้อมูลที่ต้องติดตามมากเกินไป และน่าจะมีประโยชน์เฉพาะกับโค้ดผิด ๆ แบบนี้เท่านั้น
ตอนเคยทำคอมไพเลอร์ AI มาก่อน แม้ liveness analysis ระดับองค์ประกอบของเทนเซอร์จะน่าจะมีประโยชน์จริง ๆ แต่ก็ไม่ได้ทำ เพราะเวลาในการคอมไพล์และความต้องการหน่วยความจำสูงอย่างเหลวไหล
result = ((u * (u - 1)) / 2 * (100000/u)) + (100000%u * (100000%u - 1) / 2) + r)บทความพูดถึง Ruby เวอร์ชัน ที่จะออกในอนาคต และลองดูด้วยความสงสัยแล้วพบว่า ruby 3.4.0 จะออกคริสต์มาสนี้ ส่วน ruby 3.5.0 น่าจะออกคริสต์มาสหน้า
ยังสงสัยด้วยว่า JIT ขั้นต่ำของ Python จะส่งผลอย่างไรกับลูปแบบนี้
Python 3.13 ต้องบิลด์โดยเปิด JIT ดังนั้นถ้ามีใครรันเบนช์มาร์กในสภาพแวดล้อมที่บิลด์ไว้แล้วก็น่าจะน่าสนใจ: https://drew.silcock.dev/blog/everything-you-need-to-know-ab...
เท่าที่จำได้ การปรับปรุงประสิทธิภาพก็เคยถูกใส่ใน point release ได้ด้วย
Integer#succถูกใช้บ่อยไม่ใช่แค่เพราะประสิทธิภาพ แต่เพราะ ความอ่านง่าย ด้วยเช่น ในเมธอด
#bytesของไลบรารี UUID ผมใช้สองครั้งเพื่อให้ตอนอ่านโค้ด สมองยังอยู่ใน “โหมด bit slicing”ลูป 16 ครั้งเขียนเป็น
0xF.succและส่วนที่หารด้วย 256 ในลูปเขียนเป็น0xFF.succ: https://github.com/okeeblow/DistorteD/blob/ba48d10/Globe%20G...0xF.succดีกว่า0x10กำลังมีส่วนร่วมกับ https://github.com/bddicken/languages อยู่ และหลังลองแนวทางของ Lua ก็จำได้ว่าเคยมีคนพูดถึง TruffleRuby ที่ไหนสักแห่ง แต่พอรัน
main.rbแล้ว ระหว่าง TruffleRuby กับ Ruby ปกติแทบไม่มีความต่างมากนักบางครั้ง Ruby ปกติยังเร็วกว่า TruffleRuby ด้วย
อยากตรวจสอบว่าเบนช์มาร์กความเร็ว TruffleRuby ในบทความนี้เกิดขึ้นหลังการเปลี่ยนแปลงอะไร และถ้ายืนยันได้ก็อยากเพิ่มเป็นคอมมิตในรีโพหลัก
ถ้า implementation ของ TruffleRuby เร็วกว่า Node.js จริง ๆ และอยู่ในระดับใกล้ Bun หรือ Go ก็น่าทึ่งมาก
ดูรายละเอียดที่ https://github.com/oracle/truffleruby
Ruby เร็วขึ้นจริง ๆ และโดยเฉพาะ TruffleRuby ยิ่งน่าประทับใจ
น่าเสียดาย เพราะน่าจะเป็นชุดผสมที่ส่งผลต่อประสิทธิภาพของ Ruby ได้มากที่สุด
ไม่รู้มาก่อนว่า YJIT เขียนด้วย Rust น่าสนใจมาก
ข้อเสียคือถ้าไม่มี toolchain หรือแพลตฟอร์มที่เหมาะสม อาจไม่ถูกเปิดใช้ตอนบิลด์ แต่ก็ดูเป็นการประนีประนอมที่ใช้ได้
ยังมีรีโพเปรียบเทียบภาษาที่เปิดมานานกว่าและครอบคลุมภาษามากกว่า: https://github.com/niklas-heer/speed-comparison
ป้ายชื่อแกนของกราฟกับป้ายชื่อแท่งซ้อนทับกัน และไม่มีเส้นตารางแนวตั้งด้วย
ถ้าเป็นตาราง HTML ง่าย ๆ คงดี
น่าสนใจที่ Python เป็นภาษาที่ช้าที่สุดในเบนช์มาร์ก แต่ยังเป็นภาษาที่ใช้มากที่สุดบน GitHub ณ เดือนตุลาคม 2024
ดูเหมือนมีสหสัมพันธ์ว่า ยิ่งภาษาเชื่องช้า ก็ยิ่งได้รับความนิยม
แม้ผมจะชอบ Rust ค่อนข้างลึก แต่ทุกภาษาและรันไทม์ อินเทอร์พรีเตอร์ คอมไพเลอร์ ล้วนเป็นเครื่องมือ
ขึ้นอยู่กับปัญหาและแนวทางแก้ ต้องมีชุดเครื่องมือที่ดี และถ้าเป็นโปรแกรมที่อาจรันแค่ไม่กี่ครั้ง ความเร็วรันที่ช้าก็ไม่สำคัญเท่าไร
โปรแกรมอย่าง Python, R มีกรณีแบบนั้นเยอะ
โดยรวม Ruby ช้า แต่เขียนโค้ดสนุกมาก จึงดึงดูดมือใหม่กว่า
โซลูชัน Advent of Code ของผมหน้าตาคล้ายกันอย่างน่าประหลาด เลยดูเหมือนเป็นการเปลี่ยนแปลงที่พลิกเกม