ข้ามไปยังเนื้อหา

การ Optimise สำหรับ Production

การเขียน WebAssembly ที่ถูกต้องเป็นแค่ครึ่งหนึ่งของงาน ใน production คุณต้องการให้ binary เล็กที่สุดเท่าที่เป็นไปได้ (download เร็วขึ้น cache อุ่นขึ้น) และเริ่มทำงานได้เร็วที่สุดเท่าที่เป็นไปได้ (time-to-interactive ที่ต่ำลง) บทเรียนนี้รวบรวม playbook ที่สมบูรณ์

ไม่ว่าจะใช้ toolchain ใด ให้เปิดใช้ flag การ optimise ขนาดสูงสุด:

Terminal window
# wasm-opt (standalone หรือ post-compile)
wasm-opt -Oz input.wasm -o output.wasm
# Rust ผ่าน wasm-pack
wasm-pack build --release # wasm-opt -Oz ทำงานโดยอัตโนมัติ
# Emscripten
emcc -Oz source.c -o output.wasm
# TinyGo
tinygo build -opt=z -target wasm -o output.wasm ./main.go

ข้อมูล debug (DWARF, name sections, producer metadata) อาจเพิ่มขนาด binary ได้ถึงสองเท่า ลบออกสำหรับ production

Terminal window
# ลบ custom sections ทั้งหมดด้วย wasm-opt
wasm-opt -Oz --strip-debug --strip-producers output.wasm -o output.stripped.wasm
# หรือด้วย wasm-tools
wasm-tools strip output.wasm -o output.stripped.wasm
# วัดผล
wc -c output.wasm output.stripped.wasm

เก็บสำเนาที่ไม่ได้ strip ไว้บน build server เพื่อให้สามารถ symbolicate crash reports ได้

สำหรับ Rust targets std allocator เริ่มต้น (dlmalloc ผ่าน wasm-bindgen) เพิ่มประมาณ 10 KB การเปลี่ยนเป็น wee_alloc หรือ lol_alloc ที่ใหม่กว่าสามารถประหยัดได้หลาย kilobytes:

Terminal window
# Cargo.toml
[dependencies]
wee_alloc = "0.4"
# lib.rs — แทนที่ global allocator
Terminal window
# Build (wasm-pack จัดการส่วนที่เหลือ)
wasm-pack build --release --target web

สำหรับ module ที่ไม่มี dynamic allocation เลย ให้คอมไพล์ด้วย #![no_std] และลบ allocator ออกทั้งหมด

browser สามารถเริ่มคอมไพล์ไฟล์ .wasm ขณะที่ยังดาวน์โหลดอยู่ถ้าคุณใช้ WebAssembly.instantiateStreaming ซึ่งเร็วกว่าการ fetch ก่อนแล้วค่อยคอมไพล์เสมอ

application/wasm
# Serve ด้วย MIME type ที่ถูกต้อง

ใน JavaScript:

Terminal window
// Streaming (แนะนำ)
const { instance } = await WebAssembly.instantiateStreaming(
fetch('/module.wasm'),
importObject
);
// Non-streaming (fallback สำหรับ server ที่ไม่มี MIME type ที่ถูกต้อง)
const bytes = await fetch('/module.wasm').then(r => r.arrayBuffer());
const { instance } = await WebAssembly.instantiate(bytes, importObject);

สำหรับ module ขนาดใหญ่ที่มีการเข้าชมซ้ำ ให้ cache WebAssembly.Module ที่คอมไพล์แล้วใน IndexedDB เพื่อข้ามการคอมไพล์ทั้งหมดในการโหลดครั้งถัดไป

การ optimise โดยไม่มีการวัดผลคือการเดาสุ่ม เก็บ metrics สามอย่างนี้ใน CI pipeline ของคุณ:

Terminal window
# 1. ขนาด binary (raw และ gzip)
wc -c output.wasm
gzip -c output.wasm | wc -c
# 2. การแจกแจงขนาดตามฟังก์ชัน
twiggy top output.wasm | head -20
# 3. Validate binary ที่ optimise แล้วว่าไม่เสียหาย
wasm-validate output.wasm
wasm-tools validate output.wasm

การถดถอยใน metrics ทั้งสามอย่างใดอย่างหนึ่งหลังจาก dependency update เป็นสัญญาณให้ตรวจสอบก่อน ship

ตัวเลือกBenefitCost
wasm-opt -Oz (การ optimise เชิงรุกสุด)ลด binary size และเพิ่ม speed ได้มากที่สุดในทุกขั้นตอนของ playbookbuild time นานขึ้น และ debug ยากขึ้นเพราะ code ถูกจัดเรียงใหม่จน source เดิมตามไม่ทัน
เก็บ debug info / --debug-names ไว้symbolicate crash report และ debug production ได้เพิ่มขนาด binary ที่ผู้ใช้ต้อง download จริง ขัดกับเป้าหมายของบทนี้
  • Ship .wasm ตรงจาก compiler โดยไม่รัน wasm-opt เลยสักครั้ง พลาดการลดขนาด/เพิ่มความเร็วที่ได้มาแทบฟรีๆ ในขั้นตอนที่ 1
  • กระโดดไปใช้ -Oz หรือ flag เชิงรุกทันทีโดยไม่ profile ก่อนว่าจริงๆ แล้วอะไรใหญ่หรือช้า ทำให้แก้ผิดจุด
  • Strip debug info ทุก build รวมถึง build ที่ใช้ debug จริง พอมี crash ใน production ก็ไล่ยากเพราะ stack trace ไม่มีชื่อ

💡 ตัวอย่างจากของจริง

Emscripten เรียก wasm-opt ของ Binaryen ให้อัตโนมัติเมื่อ build ด้วย -O2/-O3 อยู่แล้ว ส่วนทีมที่ ship ไปที่ Cloudflare Workers/CDN มักตั้ง budget ของขนาด binary ไว้ชัดเจน แล้วรัน playbook นี้ทั้งห้าขั้นตอนใน CI ก่อน deploy ทุกครั้ง

wasm-opt flag ไหนที่ลด binary size ได้มากที่สุด?
WebAssembly.instantiateStreaming ได้เปรียบกว่า instantiate ตรงไหน?
--strip-debug ลบอะไรออกจาก binary?
เทคนิคไหนที่กำจัด allocator ออกไปทั้งหมดสำหรับ Rust modules?