2 คะแนน โดย GN⁺ 2025-02-05 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • นักพัฒนาคนหนึ่งใช้เวลาช่วงเย็นและวันหยุดสุดสัปดาห์ตลอด 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 และ data
    • types
    • funcs
    • tables
    • mems
    • globals
    • elems
    • datas
    • start
    • imports
    • exports
    • customs
    • meta

การแก้ 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 ที่ได้เป็นฟังก์ชัน
  • การเรียกฟังก์ชันต้องมีการตั้งค่า 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 ของ opcode call เพื่อค้นหา 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 ความคิดเห็น

 
GN⁺ 2025-02-05
ความคิดเห็นบน Hacker News
  • ก่อนหน้านี้ผมเคยสร้าง Wasm interpreter ด้วย Scheme มาก่อน เลยดีใจที่เห็นมีคนลงมือทำเองเพิ่มขึ้น
    มันไม่ได้ยากอย่างที่คิด ลองอ่านสเปกแล้วลองทำดูก็น่าจะดี และไม่จำเป็นต้องอิมพลีเมนต์คำสั่งทั้งหมด แค่ทำเท่าที่รู้สึกสนุกก็พอ

  • จากมุมของคนทำอิมพลีเมนต์เหมือนกัน ขอให้ทิปว่าใน spec-test มีฟอร์แมต Wasm แบบข้อความแปลก ๆ อยู่เยอะ ซึ่งวิธีคอมไพล์ไม่ค่อยตรงไปตรงมา แต่ถ้าใช้ตัวแปลง wast2json จะสร้างคำอธิบาย JSON ที่เรียบง่ายขึ้นพร้อมกับไฟล์ Wasm ไบนารีปกติได้

    • Chicory ก็ใช้วิธีเดียวกัน: https://github.com/dylibso/chicory
      ยิ่งทำให้ 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 แพตช์หรือการปรับปรุงต่าง ๆ ก็ยินดีต้อนรับ

    • ผู้เขียนดูเหมือนจะคุ้นเคยกับ Wasm มากพออย่างแน่นอน และน่าจะรู้มากพอที่จะหลีกเลี่ยงบริษัทที่เคยพยายามจด เครื่องหมายการค้า WebAssembly
      มีการกล่าวว่า “ความกังวลที่เข้าใจได้ต่อข้อเท็จจริงที่ว่า Wasmer ซึ่งได้รับเงินลงทุนจาก VC เคยพยายามจดทะเบียน WebAssembly ซึ่งเป็นชื่อองค์กรไม่แสวงหากำไรเป็นเครื่องหมายการค้า” และนั่นคือการยอมรับความผิด
    • Wasmer มี Installed-Size: 266 MB นี่มันอะไรกันแน่
    • เท่าที่ผมรู้ จะบอกว่า Wasmer รับ Wasm-C-API ไปใช้ก็คงยังห่างไกล
      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 หรือเปล่า