บางครั้งคุณต้องการใช้ไลบรารีที่มีให้เป็นโค้ด C หรือ C++ เท่านั้น เดิมทีคือจุดที่ยอมแพ้ ก็ไม่เป็นไร เพราะตอนนี้เรามี Emscripten และ WebAssembly (หรือ Wasm) แล้ว
เครื่องมือ
ฉันตั้งเป้าหมายให้ตัวเองเพื่อหาวิธีคอมไพล์โค้ด C ที่มีอยู่บางส่วนเป็น Wasm เราได้ยินเสียงกระซิบเกี่ยวกับแบ็กเอนด์ Wasm ของ LLVM จึงเริ่มเจาะลึกเรื่องนี้ แม้ว่าวิธีนี้จะทำให้คุณคอมไพล์โปรแกรมง่ายๆ ได้ แต่คุณอาจพบปัญหาเมื่อต้องการใช้ไลบรารีมาตรฐานของ C หรือแม้แต่คอมไพล์ไฟล์หลายไฟล์ ประสบการณ์นี้ทำให้ฉันได้เรียนรู้บทเรียนสำคัญต่อไปนี้
แม้ว่า Emscripten เคยเป็นคอมไพเลอร์ C เป็น asm.js แต่ตอนนี้ก็พัฒนาขึ้นเพื่อเป้าหมาย Wasm และกำลังอยู่ระหว่างการเปลี่ยนไปใช้แบ็กเอนด์ LLVM อย่างเป็นทางการภายใน Emscripten ยังให้บริการ การติดตั้งใช้งานไลบรารีมาตรฐานของ C ที่ใช้ร่วมกับ Wasm ได้ด้วย ใช้ Emscripten แพลตฟอร์มนี้ทำงานที่ซ่อนอยู่มากมาย จำลองระบบไฟล์ จัดการหน่วยความจำ แพ็กเกจ OpenGL ด้วย WebGL และอื่นๆ อีกมากมายที่คุณไม่จำเป็นต้องพัฒนาด้วยตนเอง
แม้ว่าฟังดูเหมือนว่าคุณต้องกังวลเรื่องขนาดไฟล์ที่ใหญ่ขึ้น (เราเองก็กังวลเหมือนกัน) แต่คอมไพเลอร์ Emscripten จะนําทุกอย่างที่ไม่จําเป็นออก ในการทดสอบของฉัน โมดูล Wasm ที่ได้จะมีขนาดเหมาะสมกับตรรกะที่มี และทีม Emscripten และ WebAssembly ก็กำลังดำเนินการเพื่อทำให้มีขนาดเล็กลงในอนาคต
คุณดาวน์โหลด Emscripten ได้โดยทําตามวิธีการในเว็บไซต์หรือใช้ Homebrew หากคุณเป็นแฟนของคำสั่งที่จัดทำเป็นเอกสารเหมือนฉัน และไม่ต้องการติดตั้งสิ่งต่างๆ ในระบบเพียงเพื่อลองใช้ WebAssembly คุณสามารถใช้งานอิมเมจ Docker ที่ได้รับการดูแลเป็นอย่างดีได้ ดังนี้
$ docker pull trzeci/emscripten
$ docker run --rm -v $(pwd):/src trzeci/emscripten emcc <emcc options here>
การคอมไพล์โค้ดง่ายๆ
ลองมาดูตัวอย่างที่สมบูรณ์ที่สุดในการเขียนฟังก์ชันใน C ที่คำนวณเลขฟีโบนักชีที่ nth กัน
#include <emscripten.h>
EMSCRIPTEN_KEEPALIVE
int fib(int n) {
if(n <= 0){
return 0;
}
int i, t, a = 0, b = 1;
for (i = 1; i < n; i++) {
t = a + b;
a = b;
b = t;
}
return b;
}
หากคุณคุ้นเคยกับ C ฟังก์ชันนี้ไม่น่าแปลกใจเท่าไหร่ แม้ว่าคุณจะไม่รู้จัก C แต่รู้ JavaScript คุณก็น่าจะเข้าใจสิ่งที่กำลังเกิดขึ้น
emscripten.h
คือไฟล์ส่วนหัวที่ Emscripten ให้มา เราต้องการแค่เพื่อให้มีสิทธิ์เข้าถึงมาโคร EMSCRIPTEN_KEEPALIVE
แต่มาโครนี้มีฟังก์ชันการทำงานมากกว่ามาก
มาโครนี้จะบอกคอมไพเลอร์ว่าอย่านำฟังก์ชันออก แม้ว่าจะดูเหมือนว่าไม่ได้ใช้งานก็ตาม หากไม่ใส่มาโครดังกล่าว คอมไพเลอร์จะเพิ่มประสิทธิภาพฟังก์ชันออกไปเลย เนื่องจากไม่มีใครใช้มาโครนั้นเลย
เรามาบันทึกข้อมูลทั้งหมดนี้ในไฟล์ชื่อ fib.c
หากต้องการแปลงเป็นไฟล์ .wasm
เราต้อง
เปลี่ยนไปใช้คำสั่งของคอมไพเลอร์ emcc
ของ Emscripten ดังนี้
$ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' fib.c
มาลองพิจารณาคำสั่งนี้กัน emcc
คือคอมไพเลอร์ของ Emscripten fib.c
คือไฟล์ C จนถึงตอนนี้ทุกอย่างเรียบร้อยดี -s WASM=1
บอก Emscripten ให้ส่งไฟล์ Wasm ให้เราแทนไฟล์ asm.js
-s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]'
จะบอกให้คอมไพเลอร์ปล่อยให้ฟังก์ชัน cwrap()
พร้อมใช้งานในไฟล์ JavaScript และจะเพิ่มเติมเกี่ยวกับฟังก์ชันนี้ในภายหลัง -O3
บอกคอมไพเลอร์ให้เพิ่มประสิทธิภาพในเชิงรุก คุณเลือกตัวเลขที่ต่ำลงเพื่อลดเวลาบิลด์ได้ แต่จะทำให้ Bundle ที่ใหญ่ขึ้นได้เพราะคอมไพเลอร์อาจไม่นำโค้ดที่ไม่ได้ใช้ออก
หลังจากเรียกใช้คําสั่งแล้ว คุณควรได้รับไฟล์ JavaScript ที่ชื่อ a.out.js
และไฟล์ WebAssembly ที่ชื่อ a.out.wasm
ไฟล์ Wasm (หรือ "โมดูล") มีโค้ด C ที่คอมไพล์แล้วและควรมีขนาดเล็ก ไฟล์ JavaScript จะจัดการการโหลดและเริ่มต้นโมดูล Wasm รวมถึงให้บริการ API ที่ใช้งานง่ายขึ้น หากจำเป็น เครื่องมือนี้จะจัดการการตั้งค่ากอง กองซ้อน และฟังก์ชันการทำงานอื่นๆ ที่ระบบปฏิบัติการมักจะให้ไว้เมื่อเขียนโค้ด C ด้วย ดังนั้น ไฟล์ JavaScript จึงมีขนาดใหญ่ขึ้น
และมีน้ำหนักอยู่ที่ 19 KB (~5 KB gzip)
เรียกใช้อะไรง่ายๆ
วิธีที่ง่ายที่สุดในการโหลดและเรียกใช้โมดูลคือการใช้ไฟล์ JavaScript ที่สร้างขึ้น เมื่อโหลดไฟล์นั้นแล้ว คุณจะมี Module
ทั่วโลกให้ใช้งาน ใช้ cwrap
เพื่อสร้างฟังก์ชันเนทีฟของ JavaScript ที่จะดูแลการแปลงพารามิเตอร์เป็นสิ่งที่เหมาะกับ C และการเรียกใช้ฟังก์ชันที่รวม cwrap
จะใช้ชื่อฟังก์ชัน ประเภทผลลัพธ์ และประเภทอาร์กิวเมนต์เป็นอาร์กิวเมนต์โดยเรียงตามลำดับดังนี้
<script src="a.out.js"></script>
<script>
Module.onRuntimeInitialized = _ => {
const fib = Module.cwrap('fib', 'number', ['number']);
console.log(fib(12));
};
</script>
หากคุณเรียกใช้โค้ดนี้ คุณควรเห็น "144" ในคอนโซล ซึ่งเป็นตัวเลข Fibonacci ลำดับที่ 12
คัมภีร์ไบเบิล: การคอมไพล์ไลบรารี C
ก่อนหน้านี้ รหัส C ที่เราเขียนขึ้นโดยคำนึงถึง Wasm อย่างไรก็ตาม Use Case หลักของ WebAssembly คือการนำระบบนิเวศของไลบรารี C ที่มีอยู่มาใช้และอนุญาตให้นักพัฒนาซอฟต์แวร์นำไปใช้ในเว็บ ไลบรารีเหล่านี้มักใช้ไลบรารีมาตรฐานของ C, ระบบปฏิบัติการ, ระบบไฟล์ และอื่นๆ Emscripten มีฟีเจอร์เหล่านี้ส่วนใหญ่ แต่มีข้อจํากัดบางอย่าง
กลับไปที่เป้าหมายแรกของเรากัน นั่นคือการคอมไพล์โปรแกรมเปลี่ยนไฟล์ WebP เป็น Wasm แหล่งที่มาของตัวแปลงรหัส WebP เขียนด้วยภาษา C และพร้อมใช้งานใน GitHub รวมถึงเอกสารประกอบเกี่ยวกับ API บางส่วน นี่เป็นจุดเริ่มต้นที่ดี
$ git clone https://github.com/webmproject/libwebp
เริ่มต้นง่ายๆ กันก่อน ลองแสดง WebPGetEncoderVersion()
จาก
encode.h
ไปยัง JavaScript ด้วยการเขียนไฟล์ C ชื่อ webp.c
ดังนี้
#include "emscripten.h"
#include "src/webp/encode.h"
EMSCRIPTEN_KEEPALIVE
int version() {
return WebPGetEncoderVersion();
}
นี่เป็นโปรแกรมง่ายๆ ที่เหมาะสําหรับทดสอบว่าเรารับซอร์สโค้ดของ libwebp มาคอมไพล์ได้ไหม เนื่องจากเราไม่จําเป็นต้องใช้พารามิเตอร์หรือโครงสร้างข้อมูลที่ซับซ้อนเพื่อเรียกใช้ฟังก์ชันนี้
ในการคอมไพล์โปรแกรมนี้ เราต้องบอกโปรแกรมว่าจะค้นหาไฟล์ส่วนหัวของ libwebp ได้จากที่ใดโดยใช้แฟล็ก -I
และส่งไฟล์ C ทั้งหมดของ libwebp ที่จำเป็นต้องใช้ เราขอพูดตามตรงว่าฉันได้ส่งไฟล์ C ทั้งหมดที่พบและอาศัยคอมไพเลอร์ในการกรองทุกอย่างที่ไม่จำเป็นออก ดูจะทำงานได้ยอดเยี่ยม!
$ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' \
-I libwebp \
webp.c \
libwebp/src/{dec,dsp,demux,enc,mux,utils}/*.c
ตอนนี้เราต้องใช้เพียง HTML และ JavaScript บางอย่างเพื่อโหลดโมดูลใหม่เอี่ยมของเรา:
<script src="/a.out.js"></script>
<script>
Module.onRuntimeInitialized = async (_) => {
const api = {
version: Module.cwrap('version', 'number', []),
};
console.log(api.version());
};
</script>
และจะเห็นหมายเลขเวอร์ชันที่มีการแก้ไขในเอาต์พุต
รับรูปภาพจาก JavaScript ไปยัง Wasm
การดูหมายเลขเวอร์ชันของโปรแกรมเปลี่ยนไฟล์นั้นเป็นเรื่องที่ดี แต่การแปลงไฟล์รูปภาพจริงน่าจะน่าประทับใจกว่าใช่ไหม งั้นเรามาเริ่มกันเลย
คำถามแรกที่เราต้องตอบคือ เราจะนำรูปภาพไปยัง Wasm ได้อย่างไร
เมื่อดูที่ encoding API ของ libwebp จะพบว่า API ดังกล่าวต้องการอาร์เรย์ไบต์ในรูปแบบ RGB, RGBA, BGR หรือ BGRA แต่ Canvas API มี getImageData()
ซึ่งให้ Uint8ClampedArray ที่มีข้อมูลรูปภาพในรูปแบบ RGBA ดังนี้
async function loadImage(src) {
// Load image
const imgBlob = await fetch(src).then((resp) => resp.blob());
const img = await createImageBitmap(imgBlob);
// Make canvas same size as image
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
// Draw image onto canvas
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
return ctx.getImageData(0, 0, img.width, img.height);
}
ตอนนี้ก็เหลือเพียง "การคัดลอก" ข้อมูลจาก JavaScript ไปยังที่ดิน Wasm เท่านั้น ด้วยเหตุนี้ เราจึงต้องแสดงฟังก์ชันเพิ่มเติม 2 รายการ อันที่จัดสรรหน่วยความจำ สำหรับรูปภาพภายในพื้นที่ Wasm ส่วนอีกไฟล์หนึ่งที่ช่วยเพิ่มพื้นที่ว่างอีกครั้ง ได้แก่
EMSCRIPTEN_KEEPALIVE
uint8_t* create_buffer(int width, int height) {
return malloc(width * height * 4 * sizeof(uint8_t));
}
EMSCRIPTEN_KEEPALIVE
void destroy_buffer(uint8_t* p) {
free(p);
}
create_buffer
จัดสรรบัฟเฟอร์สําหรับรูปภาพ RGBA ซึ่งเท่ากับ 4 ไบต์ต่อพิกเซล
พ้อยเตอร์ที่ malloc()
แสดงผลคือที่อยู่ของเซลล์หน่วยความจำแรกของบัฟเฟอร์นั้น เมื่อตัวชี้ถูกส่งกลับไปยังส่วน JavaScript จะถือว่าเป็นเพียงตัวเลข หลังจากแสดงฟังก์ชันต่อ JavaScript โดยใช้ cwrap
แล้ว เราจะใช้ตัวเลขดังกล่าวเพื่อค้นหาจุดเริ่มต้นของบัฟเฟอร์และคัดลอกข้อมูลรูปภาพได้
const api = {
version: Module.cwrap('version', 'number', []),
create_buffer: Module.cwrap('create_buffer', 'number', ['number', 'number']),
destroy_buffer: Module.cwrap('destroy_buffer', '', ['number']),
};
const image = await loadImage('/image.jpg');
const p = api.create_buffer(image.width, image.height);
Module.HEAP8.set(image.data, p);
// ... call encoder ...
api.destroy_buffer(p);
รอบชิงชนะเลิศ: เข้ารหัสรูปภาพ
รูปภาพนี้มีอยู่ใน Wasm land แล้ว ได้เวลาเรียกโปรแกรมเปลี่ยนไฟล์ WebP ให้มาทำหน้าที่นี้กันแล้ว เมื่อดูจากเอกสารประกอบของ WebP พบว่า WebPEncodeRGBA
น่าจะเหมาะกับทุกอย่าง ฟังก์ชันนี้ใช้พอยน์เตอร์ไปยังรูปภาพอินพุตและขนาดของรูปภาพ รวมถึงตัวเลือกคุณภาพระหว่าง 0 ถึง 100 นอกจากนี้ยังจัดสรรบัฟเฟอร์เอาต์พุตให้เราอีกด้วย ซึ่งเราจะต้องเพิ่มพื้นที่ว่างโดยใช้ WebPFree()
เมื่อสร้างอิมเมจ WebP เสร็จแล้ว
ผลลัพธ์ของการดำเนินการเข้ารหัสคือบัฟเฟอร์เอาต์พุตและความยาว เนื่องจากฟังก์ชันใน C ไม่สามารถมีอาร์เรย์เป็นประเภทผลลัพธ์ (ยกเว้นกรณีที่เราจัดสรรหน่วยความจำแบบไดนามิก) ฉันจึงเปลี่ยนไปใช้อาร์เรย์ร่วมแบบคงที่ เราทราบดีว่าไม่ใช่ C ที่เป็นระเบียบ (อันที่จริงแล้ว ขึ้นอยู่กับว่าพอยน์เตอร์ Wasm มีความกว้าง 32 บิต) แต่เราคิดว่านี่เป็นทางลัดที่สมเหตุสมผลเพื่อให้ทุกอย่างง่ายขึ้น
int result[2];
EMSCRIPTEN_KEEPALIVE
void encode(uint8_t* img_in, int width, int height, float quality) {
uint8_t* img_out;
size_t size;
size = WebPEncodeRGBA(img_in, width, height, width * 4, quality, &img_out);
result[0] = (int)img_out;
result[1] = size;
}
EMSCRIPTEN_KEEPALIVE
void free_result(uint8_t* result) {
WebPFree(result);
}
EMSCRIPTEN_KEEPALIVE
int get_result_pointer() {
return result[0];
}
EMSCRIPTEN_KEEPALIVE
int get_result_size() {
return result[1];
}
เมื่อนำสิ่งต่างๆ ทั้งหมดนี้ไปใช้แล้ว เราสามารถเรียกใช้ฟังก์ชันการเข้ารหัส จับตัวชี้และขนาดรูปภาพ นำไปใส่ในบัฟเฟอร์ที่ดิน JavaScript ของเราเอง และปล่อยบัฟเฟอร์ Wasm ทั้งหมดที่เราจัดสรรไว้ในกระบวนการนี้
api.encode(p, image.width, image.height, 100);
const resultPointer = api.get_result_pointer();
const resultSize = api.get_result_size();
const resultView = new Uint8Array(Module.HEAP8.buffer, resultPointer, resultSize);
const result = new Uint8Array(resultView);
api.free_result(resultPointer);
คุณอาจพบข้อผิดพลาดที่ Wasm เพิ่มหน่วยความจำไม่เพียงพอที่จะรองรับทั้งรูปภาพอินพุตและเอาต์พุต ทั้งนี้ขึ้นอยู่กับขนาดของรูปภาพ
โชคดีที่วิธีแก้ปัญหานี้คือข้อความแสดงข้อผิดพลาด เราแค่ต้องเพิ่ม -s ALLOW_MEMORY_GROWTH=1
ในคำสั่งการคอมไพล์
เพียงเท่านี้ก็เรียบร้อยแล้ว เราได้รวบรวมโปรแกรมเปลี่ยนไฟล์ WebP และแปลงรูปภาพ JPEG เป็น WebP เราสามารถเปลี่ยนบัฟเฟอร์ผลลัพธ์เป็น Blob และใช้กับองค์ประกอบ <img>
เพื่อพิสูจน์ว่าใช้งานได้ดังนี้
const blob = new Blob([result], { type: 'image/webp' });
const blobURL = URL.createObjectURL(blob);
const img = document.createElement('img');
img.src = blobURL;
document.body.appendChild(img);
นี่ถือเป็นเกียรติอย่างยิ่งของรูปภาพ WebP ใหม่
บทสรุป
ซึ่งไม่ใช่การเดินในสวนสาธารณะเพื่อให้ไลบรารี C ทำงานในเบราว์เซอร์ แต่เมื่อคุณเข้าใจกระบวนการโดยรวมและวิธีการทำงานของการรับส่งข้อมูลแล้ว ก็จะง่ายขึ้นและได้ผลลัพธ์จนคาดคิด
WebAssembly เปิดโอกาสใหม่ๆ มากมายบนเว็บสำหรับการประมวลผล การคํานวณตัวเลข และเกม โปรดทราบว่า Wasm ไม่ใช่ยาครอบจักรวาลที่ควรนำไปใช้กับทุกสิ่ง แต่ Wasm เป็นเครื่องมือที่มีประโยชน์อย่างยิ่งเมื่อคุณพบปัญหาคอขวดดังกล่าว
เนื้อหาโบนัส: ทำสิ่งง่ายๆ ด้วยวิธีง่ายๆ
หากต้องการลองหลีกเลี่ยงไฟล์ JavaScript ที่สร้างขึ้น คุณอาจทำได้ กลับไปดูตัวอย่างฟีโบนักชี หากต้องการโหลดและเรียกใช้เอง ให้ทําดังนี้
<!DOCTYPE html>
<script>
(async function () {
const imports = {
env: {
memory: new WebAssembly.Memory({ initial: 1 }),
STACKTOP: 0,
},
};
const { instance } = await WebAssembly.instantiateStreaming(
fetch('/a.out.wasm'),
imports,
);
console.log(instance.exports._fib(12));
})();
</script>
โมดูล WebAssembly ที่ Emscripten สร้างขึ้นจะไม่มีหน่วยความจําให้ทํางาน เว้นแต่คุณจะให้หน่วยความจําแก่โมดูล วิธีระบุโมดูล Wasm ด้วยทุกอย่างคือการใช้ออบเจ็กต์ imports
ซึ่งเป็นพารามิเตอร์ที่ 2 ของฟังก์ชัน instantiateStreaming
โมดูล Wasm สามารถเข้าถึงทุกอย่างภายในออบเจ็กต์การนําเข้า แต่ไม่สามารถเข้าถึงสิ่งอื่นที่อยู่นอกออบเจ็กต์ ตามปกติแล้ว โมดูลที่คอมไพล์โดย Emscripting คาดหวังสิ่งจากสภาพแวดล้อม JavaScript ที่โหลด
- อันดับแรกคือ
env.memory
โมดูล Wasm ไม่รับรู้ถึงโลกภายนอกมากนัก ดังนั้นจึงต้องเพิ่มหน่วยความจำในการทำงาน ป้อนWebAssembly.Memory
ซึ่งแสดงถึงหน่วยความจําแบบเส้นตรง (ขยายได้หรือไม่ก็ได้) พารามิเตอร์การปรับขนาดจะอยู่ใน "หน่วยของหน้า WebAssembly" ซึ่งหมายความว่าโค้ดด้านบนจะจัดสรรหน่วยความจำ 1 หน้า โดยแต่ละหน้ามีขนาด 64 KiB หากไม่ได้ระบุmaximum
ตัวเลือก หน่วยความจําจะเพิ่มได้ไม่จํากัดตามทฤษฎี (ปัจจุบัน Chrome มีขีดจํากัดสูงสุด 2 GB) โมดูล WebAssembly ส่วนใหญ่ไม่ควรต้องตั้งค่าขีดจำกัดสูงสุด env.STACKTOP
กำหนดจุดที่ควรเริ่มเพิ่มกอง จำเป็นต้องใช้สแต็กเพื่อเรียกฟังก์ชันและจัดสรรหน่วยความจำสำหรับตัวแปรภายใน เนื่องจากเราไม่ได้ดำเนินการจัดการหน่วยความจำแบบไดนามิกในโปรแกรม Fibonacci ขนาดเล็ก เราจึงใช้หน่วยความจำทั้งหมดเป็นสแต็กได้ จึงใช้หน่วยความจำทั้งหมดเป็นสแต็กได้STACKTOP = 0