Mengganti hot path di JavaScript aplikasi Anda dengan WebAssembly

Selalu cepat

Dalam artikel sebelumnya, saya membahas cara WebAssembly memungkinkan Anda menghadirkan ekosistem library C/C++ ke web. Salah satu aplikasi yang secara ekstensif menggunakan library C/C++ adalah squoosh, aplikasi web kami yang memungkinkan Anda mengompresi gambar dengan berbagai codec yang telah dikompilasi dari C++ ke WebAssembly.

WebAssembly adalah mesin virtual tingkat rendah yang menjalankan bytecode yang disimpan dalam file .wasm. Kode byte ini diketik dan disusun sedemikian rupa sehingga dapat dikompilasi dan dioptimalkan untuk sistem host jauh lebih cepat daripada JavaScript. WebAssembly menyediakan lingkungan untuk menjalankan kode yang telah mempertimbangkan sandbox dan embedding sejak awal.

Menurut pengalaman saya, sebagian besar masalah performa di web disebabkan oleh tata letak yang dipaksakan dan proses rendering yang berlebihan, tetapi sesekali aplikasi perlu melakukan tugas yang mahal secara komputasi yang memerlukan banyak waktu. WebAssembly dapat membantu Anda.

Hot Path

Di squoosh, kita menulis fungsi JavaScript yang memutar buffer gambar dengan kelipatan 90 derajat. Meskipun OffscreenCanvas akan ideal untuk hal ini, elemen ini tidak didukung di seluruh browser yang kami targetkan, dan sedikit bermasalah di Chrome.

Fungsi ini melakukan iterasi pada setiap piksel gambar input dan menyalinnya ke posisi yang berbeda dalam gambar output untuk mencapai rotasi. Untuk gambar berukuran 4094x 4096 piksel (16 megapiksel), diperlukan lebih dari 16 juta iterasi blok kode dalam, yang kita sebut "jalur panas". Meskipun jumlah iterasi yang cukup besar, dua dari tiga browser yang kami uji menyelesaikan tugas dalam waktu 2 detik atau kurang. Durasi yang dapat diterima untuk jenis interaksi ini.

for (let d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
    for (let d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
    const in_idx = ((d1 * d1Multiplier) + (d2 * d2Multiplier));
    outBuffer[i] = inBuffer[in_idx];
    i += 1;
    }
}

Namun, satu browser memerlukan waktu lebih dari 8 detik. Cara browser mengoptimalkan JavaScript sangat rumit, dan mesin yang berbeda mengoptimalkan hal yang berbeda. Beberapa mengoptimalkan eksekusi mentah, beberapa mengoptimalkan interaksi dengan DOM. Dalam hal ini, kita telah mencapai jalur yang tidak dioptimalkan di satu browser.

Di sisi lain, WebAssembly dibuat sepenuhnya berdasarkan kecepatan eksekusi mentah. Jadi, jika kita ingin performa yang cepat dan dapat diprediksi di seluruh browser untuk kode seperti ini, WebAssembly dapat membantu.

WebAssembly untuk performa yang dapat diprediksi

Secara umum, JavaScript dan WebAssembly dapat mencapai performa puncak yang sama. Namun, untuk JavaScript, performa ini hanya dapat dicapai di "jalur cepat", dan sering kali sulit untuk tetap berada di "jalur cepat" tersebut. Salah satu manfaat utama yang ditawarkan WebAssembly adalah performa yang dapat diprediksi, bahkan di seluruh browser. Tipe ketat dan arsitektur tingkat rendah memungkinkan compiler membuat jaminan yang lebih kuat sehingga kode WebAssembly hanya perlu dioptimalkan sekali dan akan selalu menggunakan “jalur cepat”.

Menulis untuk WebAssembly

Sebelumnya, kita mengambil library C/C++ dan mengompilasikannya ke WebAssembly untuk menggunakan fungsinya di web. Kita tidak benar-benar menyentuh kode library, kita hanya menulis kode C/C++ dalam jumlah kecil untuk membentuk jembatan antara browser dan library. Kali ini motivasi kami berbeda: Kita ingin menulis sesuatu dari awal dengan mempertimbangkan WebAssembly sehingga kita dapat memanfaatkan keunggulan yang dimiliki WebAssembly.

Arsitektur WebAssembly

Saat menulis untuk WebAssembly, ada baiknya untuk memahami sedikit lebih lanjut tentang apa sebenarnya WebAssembly.

Mengutip WebAssembly.org:

Saat mengompilasi potongan kode C atau Rust ke WebAssembly, Anda akan mendapatkan file .wasm yang berisi deklarasi modul. Deklarasi ini terdiri dari daftar "impor" yang diharapkan modul dari lingkungannya, daftar ekspor yang modul ini sediakan untuk host (fungsi, konstanta, bagian memori), dan tentunya petunjuk biner yang sebenarnya untuk fungsi yang ada di dalamnya.

Sesuatu yang tidak saya sadari sampai saya memeriksanya: Stack yang menjadikan WebAssembly sebagai "virtual machine berbasis stack" tidak disimpan dalam bagian memori yang digunakan modul WebAssembly. Stack ini sepenuhnya bersifat internal VM dan tidak dapat diakses oleh developer web (kecuali melalui DevTools). Dengan demikian, Anda dapat menulis modul WebAssembly yang sama sekali tidak memerlukan memori tambahan dan hanya menggunakan stack internal VM.

Dalam hal ini, kita perlu menggunakan beberapa memori tambahan untuk mengizinkan akses arbitrer ke piksel gambar dan membuat versi gambar yang diputar. Inilah tujuan WebAssembly.Memory.

Pengelolaan memori

Umumnya, setelah menggunakan memori tambahan, Anda akan merasa perlu untuk mengelola memori tersebut. Bagian memori mana yang digunakan? Mana yang gratis? Misalnya, di C, Anda memiliki fungsi malloc(n) yang menemukan ruang memori n byte berturut-turut. Fungsi semacam ini juga disebut "allocator". Tentu saja, implementasi alokator yang digunakan harus disertakan dalam modul WebAssembly dan akan meningkatkan ukuran file Anda. Ukuran dan performa fungsi pengelolaan memori ini bisa sangat bervariasi, tergantung algoritma yang digunakan. Itulah sebabnya banyak bahasa yang menawarkan beberapa implementasi untuk dipilih ("dmalloc", "emmalloc", "wee_alloc", dll.).

Dalam kasus ini, kita mengetahui dimensi gambar input (dan dengan demikian dimensi gambar output) sebelum menjalankan modul WebAssembly. Di sini kita melihat peluang: Secara tradisional, kita akan meneruskan buffering RGBA gambar input sebagai parameter ke fungsi WebAssembly dan menampilkan gambar yang diputar sebagai nilai kembali. Untuk menghasilkan nilai yang ditampilkan, kita harus menggunakan alokator. Namun, karena kita mengetahui jumlah total memori yang diperlukan (dua kali ukuran gambar input, sekali untuk input dan sekali untuk output), kita dapat memasukkan gambar input ke dalam memori WebAssembly menggunakan JavaScript, menjalankan modul WebAssembly untuk menghasilkan gambar ke-2 yang diputar, lalu menggunakan JavaScript untuk membaca kembali hasilnya. Kita bisa pergi tanpa menggunakan manajemen memori sama sekali!

Banyak pilihan

Jika melihat fungsi JavaScript asli yang ingin kita ubah menjadi WebAssembly, Anda dapat melihat bahwa ini adalah kode komputasi murni tanpa API khusus JavaScript. Dengan demikian, porting kode ini ke bahasa apa pun seharusnya cukup sederhana. Kami mengevaluasi 3 bahasa berbeda yang dikompilasi ke WebAssembly: C/C++, Rust, dan AssemblyScript. Satu-satunya pertanyaan yang perlu kita jawab untuk setiap bahasa adalah: Bagaimana cara mengakses memori mentah tanpa menggunakan fungsi pengelolaan memori?

C dan Emscripten

Emscripten adalah compiler C untuk target WebAssembly. Sasaran Emscripten adalah berfungsi sebagai pengganti langsung untuk compiler C terkenal seperti GCC atau clang dan sebagian besar kompatibel dengan flag. Ini adalah bagian inti dari misi Emscripten karena ingin membuat kompilasi kode C dan C++ yang ada ke WebAssembly semudah mungkin.

Mengakses memori mentah adalah sifat dasar C dan pointer ada karena alasan tersebut:

uint8_t* ptr = (uint8_t*)0x124;
ptr[0] = 0xFF;

Di sini, kita mengubah angka 0x124 menjadi pointer ke bilangan bulat (atau byte) 8-bit tanpa tanda tangan. Cara ini secara efektif mengubah variabel ptr menjadi array yang dimulai dari alamat memori 0x124, yang dapat kita gunakan seperti array lainnya, sehingga dapat mengakses setiap byte untuk pembacaan dan penulisan. Dalam kasus ini, kita melihat buffering RGBA dari gambar yang ingin kita urutkan ulang untuk mencapai rotasi. Untuk memindahkan piksel, kita harus memindahkan 4 byte berturut-turut sekaligus (satu byte untuk setiap saluran: R, G, B, dan A). Untuk mempermudah, kita dapat membuat array bilangan bulat 32-bit tanpa tanda. Berdasarkan konvensi, gambar input akan dimulai dari alamat 4 dan gambar output akan dimulai langsung setelah gambar input berakhir:

int bpp = 4;
int imageSize = inputWidth * inputHeight * bpp;
uint32_t* inBuffer = (uint32_t*) 4;
uint32_t* outBuffer = (uint32_t*) (inBuffer + imageSize);

for (int d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
    for (int d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
    int in_idx = ((d1 * d1Multiplier) + (d2 * d2Multiplier));
    outBuffer[i] = inBuffer[in_idx];
    i += 1;
    }
}

Setelah memindahkan seluruh fungsi JavaScript ke C, kita dapat mengompilasi file C dengan emcc:

$ emcc -O3 -s ALLOW_MEMORY_GROWTH=1 -o c.js rotate.c

Seperti biasa, emscripten menghasilkan file kode lem yang disebut c.js dan modul wasm yang disebut c.wasm. Perhatikan bahwa modul wasm hanya dikompresi gzip menjadi ~260 Byte, sedangkan kode lem sekitar 3,5 KB setelah gzip. Setelah beberapa kali mencoba, kami dapat menghapus kode perekat dan membuat instance modul WebAssembly dengan API vanilla. Hal ini sering kali dapat dilakukan dengan Emscripten selama Anda tidak menggunakan apa pun dari library standar C.

Rust

Rust adalah bahasa pemrograman baru dan modern dengan sistem jenis yang kaya, tanpa runtime dan model kepemilikan yang menjamin keamanan memori dan keamanan thread. Rust juga mendukung WebAssembly sebagai fitur inti dan tim Rust telah berkontribusi banyak alat yang sangat baik ke ekosistem WebAssembly.

Salah satu alat ini adalah wasm-pack, yang dibuat oleh grup kerja Rutwasm. wasm-pack mengambil kode Anda dan mengubahnya menjadi modul yang mudah digunakan untuk web dan dapat langsung berfungsi dengan pemaket seperti webpack. wasm-pack adalah pengalaman yang sangat nyaman, tetapi saat ini hanya berfungsi untuk Rust. Grup ini mempertimbangkan untuk menambahkan dukungan untuk bahasa penargetan WebAssembly lainnya.

Di Rust, slice adalah array di C. Dan seperti di C, kita perlu membuat slice yang menggunakan alamat awal. Hal ini bertentangan dengan model keamanan memori yang diterapkan Rust, jadi untuk mendapatkan cara kita, kita harus menggunakan kata kunci unsafe, yang memungkinkan kita menulis kode yang tidak mematuhi model tersebut.

let imageSize = (inputWidth * inputHeight) as usize;
let inBuffer: &mut [u32];
let outBuffer: &mut [u32];
unsafe {
    inBuffer = slice::from_raw_parts_mut::<u32>(4 as *mut u32, imageSize);
    outBuffer = slice::from_raw_parts_mut::<u32>((imageSize * 4 + 4) as *mut u32, imageSize);
}

for d2 in 0..d2Limit {
    for d1 in 0..d1Limit {
    let in_idx = (d1Start + d1 * d1Advance) * d1Multiplier + (d2Start + d2 * d2Advance) * d2Multiplier;
    outBuffer[i as usize] = inBuffer[in_idx as usize];
    i += 1;
    }
}

Mengompilasi file Rust menggunakan

$ wasm-pack build

menghasilkan modul wasm 7,6 KB dengan sekitar 100 byte kode lem (keduanya setelah gzip).

AssemblyScript

AssemblyScript adalah project yang cukup baru yang bertujuan menjadi compiler TypeScript-to-WebAssembly. Namun, penting untuk diperhatikan bahwa kode ini tidak akan menggunakan TypeScript apa pun. AssemblyScript menggunakan sintaksis yang sama dengan TypeScript, tetapi mengganti library standar dengan library-nya sendiri. Library standar mereka membuat model kemampuan WebAssembly. Artinya, Anda tidak dapat mengompilasi TypeScript yang ada ke WebAssembly, tetapi tidak berarti Anda tidak perlu mempelajari bahasa pemrograman baru untuk menulis WebAssembly.

    for (let d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
      for (let d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
        let in_idx = ((d1 * d1Multiplier) + (d2 * d2Multiplier));
        store<u32>(offset + i * 4 + 4, load<u32>(in_idx * 4 + 4));
        i += 1;
      }
    }

Mengingat platform jenis kecil yang dimiliki fungsi rotate(), kode ini cukup mudah di-porting ke AssemblyScript. Fungsi load<T>(ptr: usize) dan store<T>(ptr: usize, value: T) disediakan oleh AssemblyScript untuk mengakses memori mentah. Untuk mengompilasi file AssemblyScript, kita hanya perlu menginstal paket npm AssemblyScript/assemblyscript dan menjalankan

$ asc rotate.ts -b assemblyscript.wasm --validate -O3

AssemblyScript akan memberi kita modul wasm ~300 Byte dan tidak kode lem. Modul ini hanya berfungsi dengan API WebAssembly vanilla.

Forensik WebAssembly

Rust yang berukuran 7,6 KB ternyata besar jika dibandingkan dengan 2 bahasa lainnya. Ada beberapa alat di ekosistem WebAssembly yang dapat membantu Anda menganalisis file WebAssembly (terlepas dari bahasa yang digunakan untuk membuatnya) dan memberi tahu Anda apa yang terjadi dan juga membantu Anda memperbaiki situasi.

Berkelok-kelok

Twiggy adalah alat lain dari tim WebAssembly Rust yang mengekstrak banyak data yang bermanfaat dari modul WebAssembly. Alat ini tidak khusus Rust dan memungkinkan Anda memeriksa hal-hal seperti grafik panggilan modul, menentukan bagian yang tidak digunakan atau berlebihan, dan mencari tahu bagian mana yang berkontribusi pada total ukuran file modul Anda. Yang terakhir dapat dilakukan dengan perintah top Twiggy:

$ twiggy top rotate_bg.wasm
Screenshot penginstalan Twiggy

Dalam hal ini, kita dapat melihat bahwa sebagian besar ukuran file berasal dari allocator. Mengejutkan karena kode kita tidak menggunakan alokasi dinamis. Faktor kontribusi besar lainnya adalah subbagian "nama fungsi".

wasm-strip

wasm-strip adalah alat dari WebAssembly Binary Toolkit, atau disingkat wabt. Alat ini berisi beberapa alat yang memungkinkan Anda memeriksa dan memanipulasi modul WebAssembly. wasm2wat adalah disassembler yang mengubah modul wasm biner menjadi format yang dapat dibaca manusia. Wabt juga berisi wat2wasm yang memungkinkan Anda mengubah format yang dapat dibaca manusia tersebut kembali menjadi modul wasm biner. Meskipun kami menggunakan dua alat pelengkap ini untuk memeriksa file WebAssembly, kami mendapati bahwa wasm-strip adalah yang paling berguna. wasm-strip menghapus bagian dan metadata yang tidak diperlukan dari modul WebAssembly:

$ wasm-strip rotate_bg.wasm

Ini mengurangi ukuran file modul karat dari 7,5KB menjadi 6,6KB (setelah gzip).

wasm-opt

wasm-opt adalah alat dari Binaryen. Alat ini menggunakan modul WebAssembly dan mencoba mengoptimalkannya untuk ukuran dan performa hanya berdasarkan bytecode. Beberapa alat seperti Emscripten sudah menjalankan alat ini, beberapa alat lainnya tidak. Merupakan ide baik untuk mencoba menyimpan beberapa {i>byte <i}tambahan dengan menggunakan alat-alat ini.

wasm-opt -O3 -o rotate_bg_opt.wasm rotate_bg.wasm

Dengan wasm-opt, kita dapat memangkas beberapa byte lagi sehingga totalnya 6,2 KB setelah gzip.

#![no_std]

Setelah berkonsultasi dan melakukan riset, kami menulis ulang kode Rust tanpa menggunakan library standar Rust, menggunakan fitur #![no_std]. Tindakan ini juga menonaktifkan alokasi memori dinamis secara keseluruhan, sehingga menghapus kode alokator dari modul kita. Mengompilasi file Rust ini dengan

$ rustc --target=wasm32-unknown-unknown -C opt-level=3 -o rust.wasm rotate.rs

menghasilkan modul wasm 1,6 KB setelah wasm-opt, wasm-strip, dan gzip. Meskipun masih lebih besar dari modul yang dihasilkan oleh C dan AssemblyScript, ukurannya cukup kecil untuk dianggap ringan.

Performa

Sebelum langsung mengambil kesimpulan berdasarkan ukuran file saja, kita melakukan perjalanan ini untuk mengoptimalkan performa, bukan ukuran file. Jadi, bagaimana cara kami mengukur performa dan apa hasilnya?

Cara menjalankan benchmark

Meskipun WebAssembly adalah format bytecode tingkat rendah, WebAssembly masih perlu dikirim melalui compiler untuk menghasilkan kode mesin khusus host. Sama seperti JavaScript, compiler bekerja dalam beberapa tahap. Sederhananya: Tahap pertama jauh lebih cepat dalam mengompilasi, tetapi cenderung menghasilkan kode yang lebih lambat. Setelah modul mulai berjalan, browser mengamati bagian mana yang sering digunakan dan mengirimkannya melalui compiler yang lebih mengoptimalkan, tetapi lebih lambat.

Kasus penggunaan kita menarik karena kode untuk memutar gambar akan digunakan sekali, mungkin dua kali. Jadi, dalam sebagian besar kasus, kita tidak akan pernah mendapatkan manfaat dari compiler pengoptimal. Hal ini penting untuk diingat saat melakukan benchmark. Menjalankan modul WebAssembly 10.000 kali dalam satu loop akan memberikan hasil yang tidak realistis. Untuk mendapatkan angka yang realistis, kita harus menjalankan modul satu kali dan membuat keputusan berdasarkan angka dari satu kali operasi tersebut.

Perbandingan performa

Perbandingan kecepatan per bahasa
Perbandingan kecepatan per browser

Kedua grafik ini adalah tampilan yang berbeda pada data yang sama. Pada grafik pertama, kita membandingkan per browser, pada grafik kedua, kita membandingkan per bahasa yang digunakan. Perhatikan bahwa saya memilih skala waktu logaritmik. Penting juga untuk memastikan bahwa semua benchmark menggunakan gambar pengujian 16 megapiksel yang sama dan mesin host yang sama, kecuali untuk satu browser, yang tidak dapat dijalankan di mesin yang sama.

Tanpa terlalu menganalisis grafik ini, jelas bahwa kita telah menyelesaikan masalah performa awal kita: Semua modul WebAssembly berjalan dalam waktu ~500 md atau kurang. Hal ini mengonfirmasi apa yang kami sampaikan di awal: WebAssembly memberi Anda performa yang dapat diprediksi. Apa pun bahasa yang kita pilih, varians antara browser dan bahasa minimal. Lebih tepatnya: Standar deviasi JavaScript di semua browser adalah ~400 md, sedangkan standar deviasi semua modul WebAssembly kami di semua browser adalah ~80 md.

Upaya

Metrik lainnya adalah jumlah upaya yang harus kita lakukan untuk membuat dan mengintegrasikan modul WebAssembly ke dalam squoosh. Sulit untuk menetapkan nilai numerik ke upaya, jadi saya tidak akan membuat grafik apa pun, tetapi ada beberapa hal yang ingin saya tunjukkan:

AssemblyScript tidak memiliki hambatan. Hal ini tidak hanya memungkinkan Anda menggunakan TypeScript untuk menulis WebAssembly, sehingga memudahkan rekan kerja saya untuk meninjau kode, tetapi juga menghasilkan modul WebAssembly bebas lem yang sangat kecil dengan performa yang baik. Alat di ekosistem TypeScript, seperti prettier dan tslint, kemungkinan akan berfungsi.

Rust yang dikombinasikan dengan wasm-pack juga sangat praktis, tetapi yang lebih unggul di project WebAssembly yang lebih besar adalah binding dan pengelolaan memori diperlukan. Kami perlu sedikit menyimpang dari {i>happy path<i} untuk mencapai ukuran file yang kompetitif.

C dan Emscripten membuat modul WebAssembly yang sangat kecil dan berperforma tinggi secara langsung, tetapi tanpa keberanian untuk langsung menggunakan kode lem dan menguranginya menjadi hal-hal yang paling penting, ukuran total (modul WebAssembly + kode lem) akhirnya cukup besar.

Kesimpulan

Jadi, bahasa apa yang harus Anda gunakan jika memiliki hot path JS dan ingin membuatnya lebih cepat atau lebih konsisten dengan WebAssembly. Seperti biasa dengan pertanyaan performa, jawabannya adalah: Tergantung. Jadi, apa yang kita kirim?

Grafik perbandingan

Membandingkan kompromi ukuran / performa modul dari berbagai bahasa yang kami gunakan, pilihan terbaik tampaknya adalah C atau AssemblyScript. Kami memutuskan untuk mengirimkan Rust. Ada beberapa alasan untuk keputusan ini: Semua codec yang dikirimkan di Squoosh sejauh ini dikompilasi menggunakan Emscripten. Kami ingin memperluas pengetahuan tentang ekosistem WebAssembly dan menggunakan bahasa yang berbeda dalam produksi. AssemblyScript adalah alternatif yang kuat, tetapi project ini relatif baru dan compiler-nya tidak sematang compiler Rust.

Meskipun perbedaan ukuran file antara Rust dan ukuran bahasa lainnya terlihat cukup drastis dalam grafik sebar, sebenarnya tidak terlalu besar: Memuat 500 B atau 1,6 KB bahkan melalui 2G memerlukan waktu kurang dari 1/10 detik. Dan Rust diharapkan akan segera menutup kesenjangan dalam hal ukuran modul.

Dalam hal performa runtime, Rust memiliki rata-rata yang lebih cepat di seluruh browser daripada AssemblyScript. Khususnya pada project yang lebih besar, Rust akan lebih cenderung menghasilkan kode yang lebih cepat tanpa memerlukan pengoptimalan kode manual. Namun, hal itu tidak boleh menghalangi Anda untuk menggunakan alat yang paling nyaman bagi Anda.

Dengan kata lain: AssemblyScript adalah penemuan yang luar biasa. Hal ini memungkinkan developer web menghasilkan modul WebAssembly tanpa harus mempelajari bahasa baru. Tim AssemblyScript sangat responsif dan secara aktif bekerja untuk meningkatkan toolchain mereka. Kami pasti akan terus memantau AssemblyScript di masa mendatang.

Update: Rust

Setelah memublikasikan artikel ini, Nick Fitzgerald dari tim Rust mengarahkan kami ke buku Rust Wasm yang bagus, yang berisi bagian tentang mengoptimalkan ukuran file. Dengan mengikuti petunjuk di sana (terutama mengaktifkan pengoptimalan waktu link dan penanganan panik manual), kami dapat menulis kode Rust “normal” dan kembali menggunakan Cargo (npm Rust) tanpa membuat ukuran file menjadi membengkak. Modul Rust berakhir dengan 370B setelah gzip. Untuk mengetahui detailnya, lihat PR yang saya buka di Squoosh.

Terima kasih khusus kepada Ashley Williams, Steve Klabnik, Nick Fitzgerald, dan Max Graey atas semua bantuan mereka dalam perjalanan ini.