2 คะแนน โดย GN⁺ 2024-12-06 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • ในเบนช์มาร์กภาษาเล็ก ๆ 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 --yjit Fibonacci ก็ลดลงอย่างมากจาก 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_each
  • range_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 stack
    • opt_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 ภายในโค้ด Ruby
    • Primitive.attr! :inline_block, :c_trace
    • Primitive.cexpr!
    • ary_fetch_next
  • แม้จะไม่ใช่ Ruby บริสุทธิ์อย่างสมบูรณ์ แต่เป็นรูปแบบที่ผสมการประเมินโค้ด C กับโครงสร้าง Ruby แต่ YJIT ก็ยัง optimize ได้มาก
  • ถ้าสร้าง array ไว้ล่วงหน้าแล้ววนซ้ำด้วย Array#each จะได้ performance ใกล้เคียงกับ Integer#times
    • Range#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 วินาที

การทดลอง 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 นี้เป็น เวอร์ชันที่ทำให้ง่ายเกินไป และไม่ได้รองรับทุกกรณีของ 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=dev
    • make 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 ความคิดเห็น

 
GN⁺ 2024-12-06
ความคิดเห็นจาก Hacker News
  • ตัวอย่างลูปดูเหมือนเป็นเบนช์มาร์กแปลก ๆ ที่รัน ลูปซ้อนกัน 1 พันล้านครั้ง และถ้าปรับแต่งด้วยมือ เวลารันมากกว่า 99% น่าจะไปกองอยู่ช่วงต้น
    ถ้าทำ liveness analysis (การวิเคราะห์ช่วงที่ค่ายังถูกใช้งาน) แยกตามองค์ประกอบของอาร์เรย์ ดูเหมือนจะตัดลูปชั้นนอกทั้งหมดออกได้ เลยสงสัยว่ามีคอมไพเลอร์ที่ทำการวิเคราะห์แบบนี้จริง ๆ ไหม
    แม้ u จะไม่รู้ค่าตอนคอมไพล์ ลูปชั้นในก็น่าจะเปลี่ยนเป็นคำสั่งไม่กี่คำสั่งได้เหมือนกัน และนี่ดูเป็นการปรับแต่งแบบมาตรฐานกว่าที่ฝั่งอย่าง clang อาจทำได้ในไม่ช้า

    • ปกติคอมไพเลอร์จะไม่ทำ liveness analysis กับ องค์ประกอบแต่ละตัวของอาร์เรย์
      มีข้อมูลที่ต้องติดตามมากเกินไป และน่าจะมีประโยชน์เฉพาะกับโค้ดผิด ๆ แบบนี้เท่านั้น
      ตอนเคยทำคอมไพเลอร์ 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...

    • Ruby ออกรีลีสในวันคริสต์มาส เสมอ เลยเป็นกำหนดการที่คาดเดาได้และน่ารัก
      เท่าที่จำได้ การปรับปรุงประสิทธิภาพก็เคยถูกใส่ใน 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 ก็น่าทึ่งมาก

    • TruffleRuby ต้องคำนึงถึง เวลาเริ่มต้น และเวลาที่ใช้กว่าจะถึงประสิทธิภาพสูงสุด ซึ่งขึ้นอยู่กับการตั้งค่ารันไทม์แบบ native/JVM
      ดูรายละเอียดที่ https://github.com/oracle/truffleruby
  • Ruby เร็วขึ้นจริง ๆ และโดยเฉพาะ TruffleRuby ยิ่งน่าประทับใจ

    • นั่นเป็นของ Oracle: https://github.com/oracle/truffleruby
    • Rails ใช้งานบน Truffle ไม่ได้ และเท่าที่เข้าใจ ช่วงหนึ่งต่อจากนี้ก็น่าจะยังยากอยู่
      น่าเสียดาย เพราะน่าจะเป็นชุดผสมที่ส่งผลต่อประสิทธิภาพของ Ruby ได้มากที่สุด
  • ไม่รู้มาก่อนว่า YJIT เขียนด้วย Rust น่าสนใจมาก

    • ตอนแรกเขียนด้วย C แล้วถูก พอร์ตไปเป็น Rust และดูเหมือนเป็นการตัดสินใจที่ดี
      ข้อเสียคือถ้าไม่มี toolchain หรือแพลตฟอร์มที่เหมาะสม อาจไม่ถูกเปิดใช้ตอนบิลด์ แต่ก็ดูเป็นการประนีประนอมที่ใช้ได้
  • ยังมีรีโพเปรียบเทียบภาษาที่เปิดมานานกว่าและครอบคลุมภาษามากกว่า: https://github.com/niklas-heer/speed-comparison

    • เป็นรีโพเปรียบเทียบภาษาอีกอันหนึ่ง แต่ รูปแบบการนำเสนอ อ่านยาก
      ป้ายชื่อแกนของกราฟกับป้ายชื่อแท่งซ้อนทับกัน และไม่มีเส้นตารางแนวตั้งด้วย
      ถ้าเป็นตาราง HTML ง่าย ๆ คงดี
  • น่าสนใจที่ Python เป็นภาษาที่ช้าที่สุดในเบนช์มาร์ก แต่ยังเป็นภาษาที่ใช้มากที่สุดบน GitHub ณ เดือนตุลาคม 2024
    ดูเหมือนมีสหสัมพันธ์ว่า ยิ่งภาษาเชื่องช้า ก็ยิ่งได้รับความนิยม

    • ถ้าจะเปรียบเทียบใหม่ก็ควรรวม เวลาคอมไพล์ ด้วย และต้องกระจายต้นทุนไปตามจำนวนครั้งที่คาดว่าจะรันบิลด์นั้น
      แม้ผมจะชอบ Rust ค่อนข้างลึก แต่ทุกภาษาและรันไทม์ อินเทอร์พรีเตอร์ คอมไพเลอร์ ล้วนเป็นเครื่องมือ
      ขึ้นอยู่กับปัญหาและแนวทางแก้ ต้องมีชุดเครื่องมือที่ดี และถ้าเป็นโปรแกรมที่อาจรันแค่ไม่กี่ครั้ง ความเร็วรันที่ช้าก็ไม่สำคัญเท่าไร
      โปรแกรมอย่าง Python, R มีกรณีแบบนั้นเยอะ
    • คล้ายอาหาร ถ้าโรย น้ำตาล ไว้ด้านบน คนก็จะชอบมากขึ้น
      โดยรวม Ruby ช้า แต่เขียนโค้ดสนุกมาก จึงดึงดูดมือใหม่กว่า
    • ภาษาที่ช้ามักเป็นภาษาระดับ สูงกว่า จึงใช้ง่ายกว่า
    • อยากรู้ว่าสหสัมพันธ์นั้นยังคงอยู่ไหมถ้าดูภาษา 20 อันดับแรกตามความนิยม
  • โซลูชัน Advent of Code ของผมหน้าตาคล้ายกันอย่างน่าประหลาด เลยดูเหมือนเป็นการเปลี่ยนแปลงที่พลิกเกม