การพัฒนา WebAssembly VM ที่เขียนด้วย C
(irreducible.io)- นักพัฒนาคนหนึ่งใช้เวลาช่วงเย็นและวันหยุดสุดสัปดาห์ตลอด 6 เดือนสร้าง WebAssembly VM ชื่อ Semblance บนพื้นฐาน C โดยทำเป็นโปรเจ็กต์การเรียนรู้ระยะยาวที่เจาะลึกสเปก แทนโปรเจ็กต์ข้างเคียงระยะสั้นที่มักจบเร็ว
- WebAssembly คือไบต์โค้ดมาตรฐานสำหรับรันโค้ดที่ไม่น่าเชื่อถือในเบราว์เซอร์ด้วย ความเร็วใกล้เคียงเนทีฟ และตอนนี้การใช้งานนอกเบราว์เซอร์ก็เพิ่มขึ้นใน Fastly, Shopify และ Zed
- เป้าหมายของ Semblance คือทำความเข้าใจ WebAssembly Core Specification และเตรียมพร้อมสำหรับการมีส่วนร่วมกับรันไทม์ระดับอุตสาหกรรมอย่าง Wasmtime ดังนั้นการรองรับ opcode ทุกตัวหรือผ่าน core test suite ทั้งหมดจึงอยู่นอกขอบเขต
- อินเทอร์พรีเตอร์จะตรวจสอบ magic bytes และเวอร์ชัน 1 ของไฟล์
.wasmก่อน จากนั้นจึงทำงานตามลำดับคือถอดรหัสเซกชัน, แก้ import, สร้างอินสแตนซ์ของโมดูล, ค้นหาฟังก์ชัน export, ตั้งค่า stack และ activation frame แล้วจึงรัน opcode switch loop - แม้จะยังไม่ใช่รันไทม์ที่สมบูรณ์แบบและยังมีความช้า memory leak กับความเสี่ยงจากโมดูลอันตรายอยู่ แต่การรัน Hello, World! ก็ทำให้บรรลุเป้าหมายด้านการเรียนรู้สเปกแล้ว
ทำไมถึงเลือก WebAssembly
- WebAssembly คือชุดคำสั่งมาตรฐานและรูปแบบไบต์โค้ดสำหรับ เครื่องเสมือนแบบสแตก
- จุดเริ่มต้นมาจากคอมไพเลอร์ C/C++ ไป JavaScript ของ Emscripten
- Emscripten แปลง LLVM IR เป็น JavaScript เพื่อให้รันโค้ด C และ C++ บนเว็บได้
- นักพัฒนาเบราว์เซอร์และโครงการ Emscripten จำกัดโค้ดที่สร้างขึ้นให้อยู่ใน JavaScript แบบย่อยที่เรียบง่ายเพื่อปรับประสิทธิภาพ และแบบย่อยนี้ก็ถูกทำให้เป็นมาตรฐานในชื่อ asm.js
- ต่อมาเพื่อหลีกเลี่ยงโอเวอร์เฮดจากการพาร์ส JavaScript จึงมีการออกแบบ Wasm เป็นรูปแบบไบต์โค้ดแบบอิสระ
- ระยะหลังการใช้งาน WebAssembly นอกเบราว์เซอร์เพิ่มขึ้นด้วย
- Edge Compute ของ Fastly และ Functions ของ Shopify ถูกสร้างขึ้นบนเอนจิน WebAssembly
- ระบบส่วนขยายของ Zed ก็ใช้ Wasm เช่นกัน
- Bytecode Alliance เป็นผู้ขับเคลื่อนความพยายามระดับอุตสาหกรรมเพื่อรองรับการใช้ WebAssembly นอกเบราว์เซอร์ พร้อมพัฒนา Wasmtime และมาตรฐาน WASI
- WASI ทำให้โมดูล WebAssembly ติดต่อกับระบบปฏิบัติการโฮสต์ผ่าน host call ที่คล้าย POSIX ได้
- มันมอบวิธีมาตรฐานสำหรับโต้ตอบกับอินเทอร์เฟซภายนอก เช่น standard input/output, ระบบไฟล์ และเครือข่าย
จากโปรเจ็กต์ข้างเคียงสู่โปรเจ็กต์เพื่อการเรียนรู้
- ก่อนหน้านี้มีรูปแบบซ้ำ ๆ คือเริ่มโปรเจ็กต์ข้างเคียงใหม่ แล้วไม่กี่สัปดาห์ก็หมดความสนใจและย้ายไปทำโปรเจ็กต์อื่น
- จึงต้องการโปรเจ็กต์ขนาดใหญ่ที่โฟกัสได้ยาวขึ้น และแตะระดับล่างของ computing stack มากกว่างานประจำที่ทำอยู่
- เนื่องจากมีโปรเจ็กต์ข้างเคียงจำนวนมากที่ถูกทิ้งไว้เพราะขาดทิศทาง จึงมองหาโปรเจ็กต์ที่มีเป้าหมายชัดเจนและมีผลลัพธ์ที่มองเห็นได้
- หลังรู้สึกว่าโปรไฟล์วิศวกรสาย generalist มีผลต่อการหางานที่มั่นคง จึงมองหาความเชี่ยวชาญที่จะช่วยสร้าง ทักษะแบบ T-shaped
- การที่ WebAssembly สามารถออกแบบสภาพแวดล้อมการประมวลผลเฉพาะทางผ่าน system call แบบกำหนดเองได้ ทำให้มันดูน่าสนใจในฐานะเครื่องมือสำหรับ platform engineering
เป้าหมายและขอบเขตของ Semblance
- ชื่อโปรเจ็กต์คือ Semblance เป็นอินเทอร์พรีเตอร์ WebAssembly ที่เขียนด้วย C
- เป้าหมายหลักคือทำความคุ้นเคยกับ WebAssembly Core Specification
- ในระยะยาวยังตั้งเป้าสะสมความรู้ด้าน WebAssembly ให้มากพอที่จะมีส่วนร่วมกับรันไทม์ระดับอุตสาหกรรมอย่าง Wasmtime ได้
- เพราะเป็นโปรเจ็กต์เพื่อการเรียนรู้ จึงไม่ได้ตั้งใจจะรองรับ opcode ทุกตัวหรือผ่าน core test suite ทั้งหมด
- เกณฑ์ความสำเร็จที่เพียงพอคือรัน “Hello, World!” ได้
- ผู้พัฒนายอมรับว่าไม่ได้มีประสบการณ์พัฒนา C ระดับมืออาชีพ และคุณภาพโค้ด C อาจยังไม่ดีนัก พร้อมเปิดรับ code review
ลำดับการรัน Hello, World!
- โค้ด C ตัวอย่างประกาศฟังก์ชันภายนอก
putsและพิมพ์"Hello, World!\n"ในhello() - สร้าง WebAssembly ด้วย Clang
--target=wasm32-nostdlib-Wl,--no-entry-Wl,--export-all-Wl,--allow-undefined-O3
- ไฟล์
hello.wasmที่ได้ถูกรันด้วยsemblance hello.wasm --invoke hello - เมื่อโปรแกรมเริ่ม จะมีการเตรียมหน่วยความจำบนสแตกสำหรับอาร์กิวเมนต์บรรทัดคำสั่ง, โมดูล WebAssembly ที่ถอดรหัสแล้ว และ store สำหรับเก็บสถานะรันไทม์
- จากนั้นพาร์สอาร์กิวเมนต์บรรทัดคำสั่ง และจบการทำงานหากค่าไม่ถูกต้อง
การถอดรหัสไบนารีและโครงสร้างโมดูล
- อินเทอร์พรีเตอร์เริ่มจากถอดรหัส binary format ของ WebAssembly
- ตรวจสอบ magic bytes
"\0asm"ที่ส่วนต้นของไฟล์ - ยืนยันว่าเวอร์ชันของไบนารีฟอร์แมตคือ
1 - หลังจากนั้นจึงถอดรหัส sections ที่บรรจุอยู่ภายใน
- เมื่อถอดรหัสเสร็จ โครงสร้าง
WasmModuleจะถูกเติมข้อมูลจากhello.wasmเช่น ฟังก์ชัน, type, import และ datatypesfuncstablesmemsglobalselemsdatasstartimportsexportscustomsmeta
การแก้ import และ env::puts
- ตอนนี้โฮสต์ฟังก์ชันที่ Semblance มีให้คือ
env::putsเพียงตัวเดียว - การจัดการ host function import ทำโดยจัดสรร native function pointer ลงใน store แล้วใส่
funcaddrที่สร้างขึ้นไปในอาร์เรย์importsของโมดูล hostcall_putsใช้ i32 offset ที่รับเข้ามาเป็นอาร์กิวเมนต์- มันดึง data pointer จาก memory instance แรกของ store
- แล้วพิมพ์สตริงที่ตำแหน่ง offset ออกทาง standard output ด้วย
printf("%s", ...)
- ระหว่างวนรายการ import หากพบรายการที่มี module name เป็น
"env"และ item name เป็น"puts"ก็จะลงทะเบียนโฮสต์ฟังก์ชันผ่านregister_hostcall_puts
การสร้างอินสแตนซ์และการรัน opcode
- หลังแก้ import แล้ว ขั้นต่อไปคือ สร้างอินสแตนซ์ ของโมดูล
- กระบวนการสร้างอินสแตนซ์มีขั้นตอนตรวจสอบรวมอยู่ด้วย
- มีการทดสอบเพื่อตรวจชนิดและยืนยันว่าโมดูลมีรูปแบบถูกต้อง
- มีการตั้งค่าเริ่มต้นให้ memory, global และ table
- หากมีฟังก์ชัน
startก็จะเรียกใช้งาน
- ผลลัพธ์จากการสร้างอินสแตนซ์คือ
WasmModuleInstซึ่งเก็บโครงสร้างสำหรับช่วงรันจริงและ exports ที่พร้อมใช้งาน - จากนั้นค้นหาฟังก์ชัน export ตามชื่อที่ระบุผ่านอาร์กิวเมนต์บรรทัดคำสั่ง
--invoke- วนดู exports แล้วคืนค่า
WasmExportInstที่ชื่อตรงกัน - ตรวจสอบว่า export ที่ได้เป็นฟังก์ชัน
- วนดู exports แล้วคืนค่า
- การเรียกฟังก์ชันต้องมีการตั้งค่า stack และ activation frame
- แกนหลักของการทำงานคือ opcode switch loop
- อ่านคำสั่ง Wasm ในเนื้อฟังก์ชันตามลำดับ
- ทำงานกับ stack และ store ตาม opcode ที่พบ
- ตัวอย่างที่รองรับ เช่น
i32.const,i64.const,f32.const,f64.const,i32.ge_s,call,nop,unreachable,end - opcode ที่ยังไม่รองรับจะพิมพ์
"unhandled opcode [...]"แล้วคืนค่า trap
คำสั่ง call และการเรียกโฮสต์ฟังก์ชัน
- การจัดการ
WasmOpCallใช้funcidxซึ่งเป็น immediate value ของ opcodecallเพื่อค้นหา function instance จาก store - จากข้อมูลชนิดของฟังก์ชัน จะมีการ pop อาร์กิวเมนต์ที่จำเป็นออกจาก stack
- จากนั้นตรวจชนิดของ function instance เพื่อแยกว่าเป็นฟังก์ชัน Wasm หรือ native host function
- หากเป็นโฮสต์ฟังก์ชัน ก็จะเรียก native call ผ่าน function pointer
hostfuncที่เก็บไว้ - ถ้ามีค่าที่คืนกลับ ก็จะ push กลับขึ้น stack
- เมื่อถึง opcode
endของฟังก์ชันhelloการทำงานของ opcode switch loop ก็จะสิ้นสุดและกลับไปยังmain
ผลลัพธ์และข้อจำกัดที่ยังเหลือ
- ผลการรันจะแสดงบน standard output ดังนี้
Hello, World!Ok []
- โปรเจ็กต์นี้สามารถรันโปรแกรม “Hello, World!” แบบเรียบง่ายได้
- การครอบคลุมของ opcode ยังไม่สมบูรณ์
- โค้ดยังรก ช้า และมี memory leak
- ยังอาจเปราะบางต่อโมดูลอันตราย
- ถึงอย่างนั้นก็ได้เรียนรู้ WebAssembly Core Specification ไปมาก และช่วยให้ก้าวออกจากขอบเขตที่คุ้นเคยในฐานะวิศวกร
- หลังจากสร้างอินเทอร์พรีเตอร์ขึ้นมาเอง ก็รู้สึกว่ามีความรู้ด้าน WebAssembly มากพอจะมีส่วนร่วมกับรันไทม์ระดับอุตสาหกรรมอย่าง Wasmtime ได้
- ต่อจากนี้อาจเพิ่ม opcode อีกบางส่วนต่อได้ แต่ตั้งใจจะปิดฉากการทำโปรเจ็กต์นี้ในฐานะโปรเจ็กต์หลักแล้ว
1 ความคิดเห็น
ความคิดเห็นบน Hacker News
ก่อนหน้านี้ผมเคยสร้าง Wasm interpreter ด้วย Scheme มาก่อน เลยดีใจที่เห็นมีคนลงมือทำเองเพิ่มขึ้น
มันไม่ได้ยากอย่างที่คิด ลองอ่านสเปกแล้วลองทำดูก็น่าจะดี และไม่จำเป็นต้องอิมพลีเมนต์คำสั่งทั้งหมด แค่ทำเท่าที่รู้สึกสนุกก็พอ
จากมุมของคนทำอิมพลีเมนต์เหมือนกัน ขอให้ทิปว่าใน spec-test มีฟอร์แมต Wasm แบบข้อความแปลก ๆ อยู่เยอะ ซึ่งวิธีคอมไพล์ไม่ค่อยตรงไปตรงมา แต่ถ้าใช้ตัวแปลง
wast2jsonจะสร้างคำอธิบาย JSON ที่เรียบง่ายขึ้นพร้อมกับไฟล์ Wasm ไบนารีปกติได้ยิ่งทำให้ test suite นี้รันได้เร็วเท่าไร ก็ยิ่งช่วยเรื่องความเร็วในการวนแก้และความถูกต้องมากขึ้น
ใช้เวลาพอสมควรกว่าจะทำให้ทั้งหมดทำงานได้ แต่พอสำเร็จแล้ว ก็ไปถึงจุดที่รันอะไรก็ได้อย่างรวดเร็วมาก และแม้ test suite จะไม่สมบูรณ์ แต่ก็ครอบคลุมได้ราว 95%: https://github.com/WebAssembly/testsuite
เคยเห็นชุดทดสอบพวกนี้มาก่อน แต่ไม่รู้ว่าจะใช้ยังไง เลยหงุดหงิดและสับสน
เกี่ยวกับ การตีความ Wasm โดยตรง บทความวิชาการนี้ค่อนข้างน่าสนใจ: https://arxiv.org/abs/2205.01183
ผมสร้าง https://github.com/peterseymour/winter โดยอิงจากสิ่งนั้น และได้เรียนรู้ว่า Wasm ไม่ได้เรียบง่ายอย่างที่คิด
คำถามจากมือใหม่: อยากรู้ว่าในสถานการณ์ที่ไม่ได้เขียนโค้ดโดยเล็งไปที่ target โดยตรง จะ ดีบัก interpreter กันอย่างไร
อีกอย่างคืออยากรู้ว่าการ fuzz สตริง opcode ช่วยได้ถึงไหน และความแตกต่างเชิงปฏิบัติระหว่างเอนจิน Wasm ฝั่งเซิร์ฟเวอร์กับเอนจินบนเบราว์เซอร์มีมากแค่ไหน รวมถึงถ้าจะเปลี่ยนจากตัวหนึ่งไปอีกฝั่งต้องใช้แรงงานมากเพียงใด
เป็นแนวทางที่น่าสนใจและเป็นงานที่ยอดเยี่ยม
ถ้าอยากดูแกนหลัก ส่วนใหญ่จะอยู่ในไฟล์นี้: https://github.com/irrio/semblance/blob/main/src/wrun.c
ลองคิดดูแล้ว ถ้าโปรเจกต์นี้ทำตาม Wasm-C-API(https://github.com/WebAssembly/wasm-c-api) เป็นอินเทอร์เฟซมาตรฐานก็น่าจะดี
มันเป็น C API อยู่แล้ว และ Wasm runtime ส่วนใหญ่ เช่น Wasmer, V8, wasmi ก็รับไปใช้ ทำให้นักพัฒนาที่คุ้นกับ API นั้นทดลองได้ง่ายขึ้น
ถ้าผู้เขียนคุ้นเคยกับ Wasm มากพอและอยากมีส่วนร่วมกับ Wasmer แพตช์หรือการปรับปรุงต่าง ๆ ก็ยินดีต้อนรับ
มีการกล่าวว่า “ความกังวลที่เข้าใจได้ต่อข้อเท็จจริงที่ว่า Wasmer ซึ่งได้รับเงินลงทุนจาก VC เคยพยายามจดทะเบียน WebAssembly ซึ่งเป็นชื่อองค์กรไม่แสวงหากำไรเป็นเครื่องหมายการค้า” และนั่นคือการยอมรับความผิด
Installed-Size: 266 MBนี่มันอะไรกันแน่https://github.com/wasmerio/wasmer/issues/2615 ถูกปล่อยทิ้งไว้แล้วปิดอัตโนมัติ
เป็นคำถามที่ชวนถกเถียงขึ้นอีกหน่อย คืออยากรู้ว่ามีความคิดจะเพิ่มคำสั่ง tail call แบบทดลองหรือไม่
ฝั่งสเปก Wasm เคยปฏิเสธเพราะเห็นว่า “ระดับสูง” เกินไป แต่คณะกรรมการ C ก็เคยปฏิเสธข้อเสนอของ Dennis Ritchie เช่นกัน
ถึงอย่างนั้นผมก็ยังเดิมพันข้าง Ritchie และดูเหมือน Rob Pike ก็เดิมพันไปทางนั้นด้วย
ถ้าไม่ใช่อย่างนั้นแล้วเขาจะสร้าง Golang ขึ้นมาทำไม? การเรียกจะเป็นระดับสูงก็ต่อเมื่อ tail call เป็นระดับสูงเท่านั้น
น่าจะลองดู Orca นะ คิดว่าน่าจะมีส่วนร่วมกับที่นั่นได้ดี
[0] https://orca-app.dev
ผมเองก็ตัดสินใจจะโฟกัสกับโปรเจกต์เดียว แทนที่จะย้ายไปมาระหว่างของใหม่ ๆ แวววาว แต่มีข้อยกเว้นคือให้ ผู้ช่วย AI ทำงานจิปาถะให้
พูดให้น้อยที่สุดก็คือค่อนข้างน่าหงุดหงิด
และผมก็กะจะเสนอด้วยว่า ถ้าต้องจัดการอาร์กิวเมนต์หลายตัวเวลาเรียกจาก C อาจใช้ไลบรารีอย่าง
libffiเวลาใช้ WebAssembly เป็น plugin API ในที่อย่าง zed ผมสงสัยว่านักพัฒนาปลั๊กอินดีบักโค้ดกันอย่างไร
เช่น สามารถดีบักด้วย breakpoint ได้ไหม หรือถ้าโค้ด crash จะได้ stack trace หรือเปล่า