กลับไปยังบล็อก
architectureperformancecompiler

ระบบปลั๊กอินคือภาษีด้านประสิทธิภาพ

คุณติดตั้ง VS Code มันเร็ว คุณเพิ่มส่วนขยาย 15 ตัว ตอนนี้ใช้เวลา 4 วินาทีในการเริ่มต้นและ Extension Host กิน RAM 800 MB เกิดอะไรขึ้น?

รูปแบบนี้ซ้ำแล้วซ้ำเล่าทุกที่: WordPress, Eclipse, Chrome, Figma, Slack แอปส่งมาเร็ว ปลั๊กอินทำให้มันช้า ไม่มีใครแปลกใจอีกแล้ว — เรายอมรับว่ามันเป็นต้นทุนของความสามารถในการขยาย

แต่ระบบปลั๊กอินไม่ใช่แค่ปัญหาประสิทธิภาพ มันเป็นปัญหาปรัชญาการออกแบบ อุตสาหกรรมสับสนระหว่าง "ความสามารถในการขยาย" กับ "ไดนามิซึมในรันไทม์" ในขณะที่คำตอบที่ดีกว่ามักเป็นการผสมรวมในเวลาคอมไพล์ ปลั๊กอินเดียวที่มีประสิทธิภาพคือปลั๊กอินที่หยุดเป็นปลั๊กอินในเวลาคอมไพล์

สเปกตรัมประสิทธิภาพของความสามารถในการขยาย

ไม่ใช่ทุกความสามารถในการขยายที่มีต้นทุนเท่ากัน มีสเปกตรัมจากศูนย์ต้นทุนถึงต้นทุนสูงสุด และอุตสาหกรรมส่วนใหญ่ตั้งอยู่ที่ปลายที่แพง:

  1. การลิงก์แบบสแตติก / โมดูลในเวลาคอมไพล์ — ไม่มีโอเวอร์เฮด ไลบรารี C, Rust crates, แพ็กเกจ Go ขอบเขตโมดูลหายไปทั้งหมดในไบนารีสุดท้าย
  2. ไลบรารีที่แชร์โหลดเมื่อเริ่มต้น — แทบไม่มี โมดูล nginx, โมดูลเคอร์เนล Linux ต้นทุนครั้งเดียวเมื่อโหลด จากนั้นเป็นการเรียกฟังก์ชันโดยตรง
  3. การ dispatch แบบไดนามิกผ่านอินเทอร์เฟซ / vtables — โอเวอร์เฮดน้อย ปลั๊กอินเอนจินเกมใน C++ การ indirection ของตัวชี้หนึ่งครั้งต่อการเรียก
  4. ปลั๊กอินที่ตีความในโปรเซสเดียวกัน — ปานกลาง ปลั๊กอิน PHP ของ WordPress, OSGi bundles ของ Eclipse ทุกการเรียกปลั๊กอินผ่านตัวตีความ
  5. ปลั๊กอินในโปรเซสแยกผ่าน IPC — มาก ส่วนขยาย VS Code, ส่วนขยาย Chrome ทุกการโต้ตอบข้ามขอบเขตโปรเซสและซีเรียลไลซ์ข้อมูล
  6. ปลั๊กอินใน sandbox ผ่าน IPC ที่ซีเรียลไลซ์ — หนัก ปลั๊กอิน Figma, content scripts ของส่วนขยายเบราว์เซอร์ การซีเรียลไลซ์ การดีซีเรียลไลซ์ และการบังคับใช้ sandbox ในทุกการเรียก

ข้อมูลเชิงลึกสำคัญ: ปลั๊กอินเดียวที่มีประสิทธิภาพคือปลั๊กอินที่หยุดเป็นปลั๊กอินในเวลาคอมไพล์ ระดับ 1 และ 2 เร็วเพราะ "ปลั๊กอิน" กลายเป็นสิ่งที่แยกแยะไม่ออกจากโค้ดโฮสต์ในอาร์ติแฟกต์สุดท้าย

ความเสียหายในโลกจริง

WordPress

ทุกปลั๊กอินเชื่อมต่อกับวงจรชีวิตของคำขอ 30 ปลั๊กอินหมายถึง 30 ชั้นของการเรียกฟังก์ชันต่อการโหลดหน้า ผลลัพธ์: ปลั๊กอิน caching มีอยู่เพียงเพื่อบรรเทาความเสียหายของปลั๊กอินอื่น ปลั๊กอินประสิทธิภาพเพื่อแก้ปัญหาประสิทธิภาพที่ปลั๊กอินสร้างขึ้น ความย้อนแย้งเขียนตัวเอง

VS Code

ส่วนขยายแชร์ event loop Node.js เดียวในโปรเซสแยก ส่วนขยายที่ทำงานผิดหนึ่งตัวบล็อกทั้งหมด Extension Host มักปรากฏเป็นตัวใช้ CPU อันดับต้นๆ บนเครื่องของนักพัฒนา Microsoft ได้สร้างเครื่องมือ profiling คำสั่ง bisect และระบบเหตุการณ์การเปิดใช้งาน — โครงสร้างพื้นฐานทั้งหมดเพื่อจัดการปัญหาที่ส่วนขยายสร้างขึ้น

Eclipse

เรื่องเตือนใจ การแก้ไข bundle OSGi โอเวอร์เฮดการโหลดคลาส กราฟ dependency ขนาดใหญ่ ครั้งหนึ่งเคยเป็น IDE ที่ได้รับความนิยมมากที่สุด ตอนนี้ถูกทิ้งโดยนักพัฒนากระแสหลักเป็นส่วนใหญ่ สถาปัตยกรรมปลั๊กอินที่ควรจะเป็นจุดแข็งที่ยิ่งใหญ่ที่สุดกลับกลายเป็นจุดอ่อนที่กำหนดตัวตน

ตัว Electron เอง

ปัญหาปลั๊กอินในระดับแพลตฟอร์ม ทุกแอป Electron ส่งรันไทม์ Chromium + Node.js เต็มรูปแบบ VS Code คือ Electron Slack คือ Electron Discord คือ Electron แต่ละตัวใช้ RAM 300–500 MB อย่างอิสระเพื่อเรนเดอร์สิ่งที่เป็นหน้าต่างแชทหรือเท็กซ์เอดิเตอร์โดยพื้นฐาน "ปลั๊กอิน" ที่นี่คือแพลตฟอร์มเว็บทั้งหมด บันเดิลใหม่สำหรับทุกแอปพลิเคชัน

ทำไมอุตสาหกรรมยังคงเลือกปลั๊กอิน

ถ้าปลั๊กอินแพงขนาดนั้น ทำไมทุกคนยังคงสร้างพวกมัน? เหตุผลส่วนใหญ่เป็นเรื่ององค์กร ไม่ใช่เทคนิค:

  • ประสบการณ์นักพัฒนา — ปลั๊กอินเขียนง่ายเมื่อคุณไม่สนใจเรื่องประสิทธิภาพ ส่งไฟล์ JS เชื่อมต่อกับอีเวนต์บางอย่าง เสร็จ
  • การเติบโตของระบบนิเวศ — ปลั๊กอินสร้างผลกระทบเครือข่ายและการมีส่วนร่วมของชุมชน marketplace ที่มี 30,000 ส่วนขยายเป็นคูน้ำที่ทรงพลัง
  • ความสะดวกขององค์กร — ปลั๊กอินให้ทีมเลื่อนการตัดสินใจออกแบบ "มีคนจะเขียนปลั๊กอินสำหรับสิ่งนั้น" เป็นสถาปัตยกรรมที่เทียบเท่ากับ "เราจะแก้ไขในขั้นตอนหลังการผลิต"
  • โมเดลธุรกิจ — marketplace ปลั๊กอินสร้างรายได้และ lock-in แพลตฟอร์มจับมูลค่าจากระบบนิเวศ

ความจริงที่ไม่สบายใจ: ปลั๊กอินมักเป็นวิธีหลีกเลี่ยงการตัดสินใจสถาปัตยกรรมที่ยากเกี่ยวกับสิ่งที่ควรอยู่ในแกนกลาง พวกมันให้คุณส่งบางสิ่งที่ไม่สมบูรณ์และเรียกมันว่า "ขยายได้"

ทางเลือก: การผสมรวมในเวลาคอมไพล์

จะเป็นอย่างไรถ้าความสามารถในการขยายเกิดขึ้นในเวลา build แทนที่จะเป็นรันไทม์?

นี่ไม่ใช่สมมติฐาน มีตัวอย่างที่พิสูจน์แล้วในภาษาระบบ:

  • Rust proc macros — โค้ดที่รันในเวลาคอมไพล์และสร้างโค้ดเนทีฟที่ไม่มีโอเวอร์เฮด การซีเรียลไลซ์ Serde, การตั้งค่ารันไทม์ Tokio, การเราต์ Axum — ทั้งหมดแก้ไขก่อนที่โปรแกรมของคุณจะเริ่ม
  • Zig comptime — การรันในเวลาคอมไพล์ที่กำจัดการแยกสาขาในรันไทม์ทั้งหมด โครงสร้างข้อมูลเจเนอริกถูกมอโนมอร์ฟไอซ์ การกำหนดค่าถูกแก้ไข โค้ดที่ตายถูกกำจัด สิ่งที่เหลือคือสิ่งที่รันจริง
  • C++ templates / constexpr — โพลีมอร์ฟิซึมในเวลาคอมไพล์ที่ไม่มีต้นทุนในรันไทม์ STL บรรลุประสิทธิภาพที่ยอดเยี่ยมเพราะทุกอัลกอริทึมเจเนอริกเฉพาะทางในเวลาคอมไพล์
  • Tree-shaking ใน bundlers — เวอร์ชันบางส่วนที่ไม่สมบูรณ์แบบของแนวคิดนี้ที่นำไปใช้กับ JavaScript Webpack และ Rollup กำจัด exports ที่ไม่ได้ใช้ในเวลา build ข้อจำกัดคือมันทำได้แค่ลบโค้ด ไม่ใช่เฉพาะทาง

รูปแบบสอดคล้องกัน: ย้ายการตัดสินใจจากรันไทม์ไปเวลา build สิ่งที่คุณไม่รวมไม่มีต้นทุน สิ่งที่คุณรวมคอมไพล์เป็นโค้ดเนทีฟโดยไม่มีการ indirection ขอบเขตโมดูลกลายเป็นเครื่องมือจัดระเบียบระดับซอร์สโค้ด ไม่ใช่ขอบเขตประสิทธิภาพในรันไทม์

สิ่งนี้หมายความว่าอย่างไรสำหรับ TypeScript

TypeScript เป็นภาษาที่ได้รับความนิยมมากที่สุดสำหรับสร้างเครื่องมือที่ขยายได้ — และแย่ที่สุดด้านประสิทธิภาพรันไทม์ ระบบนิเวศ TypeScript ทั้งหมดรันบน Node.js ซึ่งรันบน V8 ซึ่ง JIT-คอมไพล์ JavaScript ทุกเลเยอร์เพิ่มโอเวอร์เฮด: เวลาอุ่น JIT, การหยุดเก็บขยะ, dynamic dispatch สำหรับทุกการเข้าถึงคุณสมบัติ, ขอบเขต IPC ระหว่างโปรเซส

นี่คือจุดที่ Perry เข้ามา Perry คอมไพล์ TypeScript โดยตรงเป็นไบนารีเนทีฟ ไม่มี V8 ไม่มีการอุ่น JIT ไม่มีการหยุดเก็บขยะ ไม่มีขอบเขต IPC

เมื่อโมดูลของคุณคอมไพล์เป็นโค้ดเนทีฟ "ปลั๊กอิน" กลายเป็นแค่... โมดูล มันผสมรวมในเวลา build ไบนารีสุดท้ายมีโอเวอร์เฮดปลั๊กอินเป็นศูนย์เพราะไม่มีปลั๊กอิน — แค่โค้ดเนทีฟ ตัวจัดการเส้นทาง Express, ฟังก์ชัน middleware, ไลบรารียูทิลิตี้ — ทั้งหมดคอมไพล์เป็นการเรียกฟังก์ชันโดยตรงในไบนารีเดียวกัน ไม่มีการโหลดแบบไดนามิก ไม่มีการซีเรียลไลซ์ ไม่มีขอบเขตโปรเซส

terminal

# Your app, your dependencies, your "plugins" — one binary

$ perry compile server.ts -o server

Compiling server.ts + 43 modules...

Built executable: server (1.8 MB, 0.7s)

$ ./server

Listening on port 3000

นี่ไม่ใช่ทฤษฎี Perry คอมไพล์เฟรมเวิร์ก TypeScript จากโลกจริงแล้ว — Hono, tRPC, Strapi — เป็นไบนารีเนทีฟ ARM64 ขนาดต่ำกว่า 2 MB ในเวลาน้อยกว่าหนึ่งวินาที โมดูลที่ประกอบเป็นเฟรมเวิร์กเหล่านั้นถูกคอมไพล์ ลิงก์ และอินไลน์เข้าไปในไฟล์เรียกทำงานเดียว สิ่งที่จะเป็นสถาปัตยกรรมปลั๊กอินที่มีโอเวอร์เฮดรันไทม์ใน Node.js กลายเป็นการผสมรวมที่ไม่มีต้นทุนในไบนารี Perry

ความสามารถในการขยายที่คุณต้องการจริงๆ

การคัดค้านนั้นชัดเจน: "แต่ฉันต้องการความสามารถในการขยายในรันไทม์ ผู้ใช้ต้องติดตั้งปลั๊กอินโดยไม่ต้องคอมไพล์ใหม่"

จริงหรือ? สำหรับแอปพลิเคชันส่วนใหญ่ ชุดส่วนขยายเป็นที่รู้จักในเวลา build คุณเลือก middleware Express, ไดรเวอร์ฐานข้อมูล, ไลบรารี auth, เฟรมเวิร์กการ logging ของคุณ — แล้วก็ deploy "ความสามารถในการขยาย" อยู่ใน package.json ของคุณ แก้ไขเมื่อ npm install ไม่ใช่ในรันไทม์

แอปพลิเคชันที่ต้องการการโหลดปลั๊กอินในรันไทม์จริงๆ — VS Code, WordPress, เบราว์เซอร์ — เป็นข้อยกเว้น ไม่ใช่กฎ และแม้แต่สิ่งเหล่านั้นก็จ่ายราคาสูงสำหรับมัน สำหรับทุกอย่างอื่น การผสมรวมในเวลาคอมไพล์ให้ความยืดหยุ่นเดียวกันโดยไม่มีโอเวอร์เฮด

ความแตกต่างคือความซื่อสัตย์ทางสถาปัตยกรรม แทนที่จะแกล้งทำเป็นว่าทุกแอปพลิเคชันต้องการระบบปลั๊กอิน คุณถาม: ความสามารถในการขยายนี้ต้องเกิดขึ้นในรันไทม์ หรือคอมไพเลอร์สามารถทำงานได้?

เส้นทางข้างหน้า

การติดสถาปัตยกรรมปลั๊กอินของอุตสาหกรรมเป็นอาการของการยอมรับโอเวอร์เฮดรันไทม์ว่าหลีกเลี่ยงไม่ได้ มันไม่ใช่ คอมไพเลอร์สามารถทำงานได้ การผสมรวมในเวลา build ให้ความสามารถในการขยายโดยไม่มีภาษี

เราสร้าง Perry เพราะเราเชื่อว่านักพัฒนา TypeScript สมควรได้รับประสิทธิภาพเนทีฟโดยไม่ต้องสละภาษาที่พวกเขารัก โมดูลของคุณควรผสมรวมในเวลา build คอมไพล์เป็นการเรียกฟังก์ชันโดยตรง และรันโดยไม่มีโอเวอร์เฮดของรันไทม์ที่มีอยู่เพียงเพื่อทำให้ "ความสามารถในการขยาย" เป็นไปได้

ระบบปลั๊กอินที่เร็วที่สุดคือระบบที่ไม่มีอยู่ในรันไทม์