- วิเคราะห์ข้อจำกัดด้านประสิทธิภาพของ Bundler พร้อมเปรียบเทียบเหตุผลที่ตัวจัดการแพ็กเกจ Python อย่าง uv ทำงานได้รวดเร็ว
- ความเร็วของ uv ไม่ได้มาจากการใช้ภาษา Rust แต่เกิดจากการออกแบบเชิงโครงสร้าง เช่น การดาวน์โหลดแบบขนาน, แคชส่วนกลาง, การจัดการ dependency ที่อิงเมทาดาทา
- Bundler มีข้อจำกัดในการประมวลผลแบบขนาน เพราะ ผูกกระบวนการดาวน์โหลดและติดตั้งเข้าด้วยกัน แต่หากแยกสองส่วนนี้ออกจากกันก็มีโอกาสปรับปรุงได้มาก
- สามารถลดความซ้ำซ้อนระหว่าง RubyGems กับ Bundler ได้ด้วย การรวมแคชส่วนกลาง, การติดตั้งด้วย hardlink, การรวมตัวแก้ dependency แบบ PubGrub
- แม้ไม่ต้องเขียนใหม่ทั้งระบบด้วยภาษาอื่น การปรับปรุงประสิทธิภาพส่วนใหญ่ก็ทำได้ภายในโค้ด Ruby และอาจเข้าใกล้ความเร็วระดับ uv ได้
การเปรียบเทียบประสิทธิภาพระหว่าง Bundler กับ uv
- เริ่มจากคำถามใน RailsWorld ว่า “ทำไม Bundler ถึงไม่เร็วเท่า uv?” จึงมีการตรวจสอบคอขวดด้านประสิทธิภาพของ Bundler
- ผู้เขียนมั่นใจว่า Bundler สามารถไปถึงระดับความเร็วของ uv ได้ และย้ำว่า ความต่างด้านประสิทธิภาพเป็นปัญหาเรื่องการออกแบบ ไม่ใช่ภาษา
- อ้างถึงบทความของ Andrew Nesbitt “How uv got so fast” เพื่อนำแนวทางการเพิ่มประสิทธิภาพหลักของ uv มาวิเคราะห์ว่าสามารถใช้กับ Bundler ได้หรือไม่
ต้องเขียนใหม่ด้วย Rust หรือไม่
- แม้ uv จะเขียนด้วย Rust จริง แต่ สาเหตุหลักของความเร็วไม่ใช่ตัว Rust เอง
- หากสามารถกำจัดคอขวดของ Bundler ได้จนเหลือเพียง “การเขียนใหม่ด้วย Rust” เป็นทางปรับปรุงเดียว นั่นก็ถือว่าประสบความสำเร็จ
- การเขียนใหม่ด้วย Rust ให้อิสระในการทดลองออกแบบโดยไม่ต้องแบกรับข้อจำกัดด้านความเข้ากันได้เดิม แต่ไม่ใช่เงื่อนไขจำเป็น
คอขวดเชิงโครงสร้างของ Bundler
- Bundler ผูกรวมการดาวน์โหลด gem และการติดตั้งไว้ในเมธอดเดียว ทำให้ไม่สามารถดาวน์โหลดแบบขนานได้
- ในโค้ดตัวอย่าง เมธอด
install เรียก fetch_gem_if_not_cached และ install ต่อเนื่องกัน
- ด้วยเหตุนี้ gem ที่มีความสัมพันธ์แบบพึ่งพากัน (
a -> b -> c) จึงติดตั้งได้แบบลำดับเท่านั้น
- จากการทดลอง หากมี dependency จะใช้เวลามากกว่า 9 วินาที แต่ gem ที่เป็นอิสระต่อกัน (
d, e, f) สามารถดาวน์โหลดแบบขนานและเสร็จภายใน 4 วินาที
- หากแยกการดาวน์โหลดออกจากการติดตั้ง ก็ยังคงกฎ dependency ไว้ได้พร้อมเปิดทางให้ประมวลผลแบบขนาน
- มีข้อเสนอให้แยกเป็น 4 ขั้นตอน (ดาวน์โหลด → แตกไฟล์ → คอมไพล์ → ติดตั้ง)
- สำหรับ gem ที่เป็น Ruby ล้วน สามารถผ่อนเงื่อนไขลำดับการติดตั้ง dependency เพื่อเพิ่มความเร็วได้อีก
การเพิ่มประสิทธิภาพแคชและการติดตั้ง
- แนวทางของ uv เรื่องแคชส่วนกลางและการติดตั้งด้วย hardlink สามารถนำมาใช้กับ Bundler ได้เช่นกัน
- ปัจจุบัน Bundler และ RubyGems ใช้แคชแยกกันตามเวอร์ชันของ Ruby
- จึงควรรวมเป็นแคชที่ใช้ร่วมกันบนพื้นฐานของ
$XDG_CACHE_HOME
- การติดตั้งด้วย hardlink จะทำได้หลังจากรวมแคชแล้ว
- แม้ Bundler จะใช้ตัวแก้ dependency แบบ PubGrub อยู่แล้ว แต่ RubyGems ยังใช้ molinillo
- การรวมตัวแก้ของทั้งสองระบบคือกุญแจสำคัญในการลดหนี้ทางเทคนิค
ความเป็นไปได้ในการนำแนวทางเพิ่มประสิทธิภาพที่เกี่ยวกับ Rust มาปรับใช้
- Zero-copy deserialization อาจนำมาปรับใช้ได้บางส่วนในขั้นตอน parse YAML ของ RubyGems
- GVL (Global VM Lock) ของ Ruby ไม่ได้เป็นข้อจำกัดใหญ่ต่อการประมวลผลแบบขนานในงานที่เน้น IO
- การทำงานของ IO และ ZLIB จะปล่อย GVL จึงสามารถรันแบบขนานได้
- อย่างไรก็ตาม การเขียนไฟล์ขนาดเล็กมี overhead จากการจัดการ GVL ซึ่งทำให้ประสิทธิภาพลดลง
- ขณะนี้มีงานปรับปรุงเรื่องนี้อยู่ภายใน Ruby
- การเพิ่มประสิทธิภาพการเปรียบเทียบเวอร์ชัน: uv เข้ารหัสเวอร์ชันเป็นจำนวนเต็ม
u64 เพื่อให้เปรียบเทียบได้เร็วขึ้น
- ฝั่ง Ruby ก็สามารถแปลง
Gem::Version ให้เป็นจำนวนเต็มเพื่อปรับปรุงประสิทธิภาพของตัวแก้ dependency ได้
- เคยมีความพยายามรีแฟกเตอร์ในลักษณะนี้แล้ว แต่ถูกพักไว้เพราะปัญหาความเข้ากันได้ย้อนหลัง
บทสรุปและแผนถัดไป
- ความเร็วของ uv มาจากการออกแบบที่ตัดงานที่ไม่จำเป็นออกไปมากกว่าตัวภาษา และ Bundler ก็สามารถพัฒนาไปในทิศทางเดียวกันได้
- RubyGems และ Bundler มีโครงสร้างการจัดการแพ็กเกจสมัยใหม่อยู่แล้ว ทำให้การไปถึงระดับความเร็วของ uv เป็นเรื่องที่เป็นไปได้จริง
- ความท้าทายใหญ่ที่สุดคือโค้ดแบบ legacy และการรักษาความเข้ากันได้
- แม้ไม่ต้องเขียนใหม่ด้วย Rust แต่การเพิ่มประสิทธิภาพ 99% สามารถทำได้ภายในโค้ด Ruby ส่วนอีก 1% ที่เหลือมีผลเพียงเล็กน้อย
- ในบทความถัดไป ผู้เขียนจะพูดถึงการทำ profiling จริงของ Bundler และ RubyGems รวมถึงสาเหตุของคอขวดอย่างเจาะจง
2 ความคิดเห็น
พูดนั้นง่าย โชว์โค้ดมาเลย!
ความเห็นจาก Hacker News
ผมไม่ได้รู้โครงสร้างของ Bundler ลึกมากนัก แต่คิดว่าการปรับปรุงที่ใหญ่ที่สุดคือการนำ การออกแบบแคชของ uv มาใช้
หนึ่งในเหตุผลสำคัญที่ uv เร็วคือโครงสร้างแคช และสิ่งนี้ก็สามารถทำซ้ำได้ในภาษาและ ecosystem อื่น ๆ
อย่างไรก็ตาม ส่วนที่ละเลย upper bound ของ
requires-pythonไม่ได้ทำไปเพื่อประสิทธิภาพ แต่เพื่อ การแก้ dependency ที่ดีกว่าตัวอย่างเช่น ถ้าโปรเจกต์ต้องการ Python 3.8 ขึ้นไป แต่ dependency ตัวหนึ่งกำหนดข้อจำกัด
<4ก็จะทำให้ติดตั้งบน Python 4 ไม่ได้uv จะแก้ปัญหาสำหรับทุกเวอร์ชันที่รองรับอยู่แล้ว ดังนั้นการไม่สนใจ upper bound จึงแทบไม่ได้ช่วยประหยัดเวลา
ดูการอภิปรายที่เกี่ยวข้องได้ใน ฟอรัม Python Discuss
หลัง PEP 658 Simple Repository API ของ Python ก็ให้เมทาดาทาโดยตรงได้แล้ว และ RubyGems.org ก็มีข้อมูลคล้ายกันอยู่แล้ว
แต่เราจะรู้ได้ว่าเป็น native extension หรือไม่ ก็ต่อเมื่อแตกไฟล์ gem ออกมา
จึงมีข้อเสนอว่าถ้าเพิ่มข้อมูลนี้เข้าไปในเมทาดาทาของ RubyGems.org โดยตรง ก็น่าจะทำให้ทำ dependency install tree แบบขนานได้ทั้งหมด
ตอนที่เคยทำงานกับ RubyGems.org ผมจำได้ว่าเมทาดาทาถูกดึงออกมาตามแต่ละเวอร์ชัน
ถ้าจะทำก็ต้องประมวลผล gemspec ของเวอร์ชันเก่าใหม่ ซึ่งอาจเป็น การเปลี่ยนเมทาดาทาที่มีความเสี่ยง
เพราะงั้นคงใช้กับเวอร์ชันเก่าได้ยาก แต่ต่อไปข้างหน้าน่าจะปรับปรุงให้รู้ลำดับการติดตั้งได้โดยไม่ต้อง unpack
ผมชอบที่ Aaron โฟกัสกับ การปรับปรุงอัลกอริทึมที่ใช้งานได้จริง มากกว่าการเขียน Bundler ใหม่ด้วย Rust
สภาพแวดล้อมที่มีเครื่องมือจัดการหลายตัวกับ Ruby หลายเวอร์ชันปนกันเป็น สภาพแวดล้อมที่ชวนสับสน ซึ่งน่าหงุดหงิดมาก
ปัญหาไม่ได้มีแค่เรื่องความเร็ว แต่เป็นเรื่อง อำนาจการควบคุมและทิศทางของ ecosystem
ตลอด 10 ปีที่ผ่านมา Ruby เน้นเรื่องความเร็ว แต่จริง ๆ แล้วคุณภาพเอกสารกับการดูแลชุมชนสำคัญกว่า
ถึงเวลาที่ต้องคิดอย่างจริงจังว่าทำไมภาษานี้ถึงถดถอย และต้องผลักดันไอเดียที่หลากหลายมากขึ้น
มีบทความที่เกี่ยวข้องล่าสุดคือ How uv got so fast (ธันวาคม 2025, 457 คอมเมนต์)
ถ้าจะทำให้ RubyGems เร็วขึ้น กุญแจสำคัญคือการทำ รายการไฟล์ของแต่ละ gem ให้เป็น registry/database
แบบนี้จะได้ไม่ต้องสแกนไฟล์ระบบทุกครั้งที่
requireถ้าแก้ gem โดยตรงก็ต้องแฮชเมทาดาทาใหม่ แต่เอาเข้าจริงก็ไม่ได้แนะนำให้แก้ด้วยมือตั้งแต่แรกอยู่แล้ว
ตอนนี้มันคงเก่าไปแล้ว แต่ก็ยังเป็นมินิโปรเจกต์ที่ผมผูกพันอยู่
โค้ด: fastup
ปัญหาจริงคือ
$LOAD_PATHเพิ่ม gem ทุกตัวเข้าไปจนเกิด combinatorial explosion ในเชิงโครงสร้างการที่มีหลายโปรเจกต์ทำแคชขึ้นมาก็เป็นหลักฐานว่านี่คือปัญหาจริง
เมื่อก่อนแอปใช้เวลาสตาร์ตเป็นนาที แต่ผมเคยลดลงได้ เป็นระดับนาที ด้วยการจัดการ load path
ผมเคยเสนอให้รวม bootsnap เข้ากับ bundler แต่ถูกปฏิเสธ
คำอธิบายโครงสร้างของ RubyGems น่าสนใจดี
gem เป็นไฟล์ tar และภายในมี YAML GemSpec ที่ประกาศ dependency
RubyGems.org ให้ข้อมูลนี้ผ่าน API อยู่แล้ว จึงตรวจสอบ dependency ได้โดยไม่ต้อง eval
แต่ YAML เป็น ฟอร์แมตที่ parse ได้ไม่มีประสิทธิภาพนัก ดังนั้นทางเลือกอย่าง JSON หรือ protobuf อาจดีกว่า
ถึงอย่างนั้น ถ้า gemserver ส่งคืนข้อมูล dependency อยู่แล้ว ก็คงไม่ใช่ปัญหาใหญ่มาก
เช่น โครงสร้างที่มีแค่เวอร์ชัน, dependency และแฮช
นี่ก็เป็นเหตุผลที่ uv เร็ว — คำนวณ dependency ได้โดยไม่ต้องดาวน์โหลดแพ็กเกจ
เมื่อก่อนผมเคยทำ วิดีโอต้นแบบ ของวิธีติดตั้ง gem ที่น่าจะดีกว่านี้
how_gems_should_be.mov
fibers ของ Ruby (หรือไลบรารี Async) มักถูกประเมินค่าสูงเกินไป
เช่นเดียวกับ thread ปัญหาการประสานงานระดับสูงอย่าง connection pool ก็ยังมีอยู่
ถึงอย่างนั้น ถ้าทำงานติดตั้งที่ติดคอขวดด้าน IO แบบ asynchronous ก็ยังเห็น การเพิ่มประสิทธิภาพที่มีนัยสำคัญ ได้
น่าจะประมาณนี้
ตอนนี้กำลังพิจารณาไอเดีย “global cache ที่แชร์กันระหว่าง bundler ทุกอินสแตนซ์”
ระยะยาวน่าจะได้ประโยชน์มาก แต่ยังประเมินอยู่ว่ามีความซับซ้อนแฝงหรือไม่
issue ที่เกี่ยวข้อง: rubygems #7249
Ruby ไม่ใช่รายแรกที่แก้ปัญหานี้ เพราะงั้นตอนนี้ถึงเวลาที่จะได้รับประโยชน์จากมันแล้ว
หลักพื้นฐานของการ optimize นั้นง่ายมาก — ไม่ทำอะไรเลยคือเร็วที่สุด
การไม่ทำสิ่งที่ไม่จำเป็นตั้งแต่แรก ต่างหากคือ optimization ที่แท้จริง