Mengganti hot path di JavaScript aplikasi Anda dengan WebAssembly

Produk ini selalu cepat, jadi

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

WebAssembly adalah virtual machine 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.

Berdasarkan pengalaman saya, sebagian besar masalah performa di web disebabkan oleh tata letak paksa dan cat yang berlebihan, tetapi terkadang aplikasi perlu melakukan tugas yang mahal secara komputasi dan membutuhkan banyak waktu. WebAssembly dapat membantu Anda.

{i>Hot Path<i}

Di squoosh, kami menulis fungsi JavaScript yang memutar buffer gambar sebanyak kelipatan 90 derajat. Meskipun OffscreenCanvas akan cocok untuk hal ini, ia 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 4094px x 4096px (16 megapiksel), diperlukan lebih dari 16 juta iterasi blok kode bagian dalam, yang kami sebut sebagai "hot path". 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 adalah cara yang sangat rumit, dan mesin yang berbeda mengoptimalkannya untuk hal yang berbeda. Sebagian mengoptimalkan eksekusi mentah, sebagian lainnya mengoptimalkan interaksi dengan DOM. Dalam hal ini, kita menemukan jalur yang tidak dioptimalkan di satu browser.

Di sisi lain, WebAssembly dibangun 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 kinerja puncak yang sama. Namun, untuk JavaScript, performa ini hanya dapat dijangkau 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. Mengetik yang ketat dan arsitektur tingkat rendah memungkinkan compiler memberikan jaminan yang lebih kuat sehingga kode WebAssembly hanya perlu dioptimalkan satu kali dan akan selalu menggunakan “jalur cepat”.

Menulis untuk WebAssembly

Sebelumnya, kami mengambil library C/C++ dan mengompilasinya ke WebAssembly untuk menggunakan fungsinya di web. Kita tidak benar-benar menyentuh kode library, kami hanya menulis sejumlah kecil kode C/C++ untuk membentuk jembatan antara browser dan library. Kali ini motivasi kita 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 banyak tentang apa itu WebAssembly.

Untuk mengutip WebAssembly.org:

Saat mengompilasi sepotong 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 disediakan modul ini untuk host (fungsi, konstanta, potongan memori), dan tentu saja petunjuk biner sebenarnya untuk fungsi yang ada di dalamnya.

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

Dalam kasus ini, kita perlu menggunakan beberapa memori tambahan untuk memungkinkan akses arbitrer ke piksel gambar dan menghasilkan versi yang diputar dari gambar tersebut. Inilah fungsi WebAssembly.Memory.

Manajemen memori

Umumnya, setelah menggunakan memori tambahan, Anda akan merasa perlu untuk mengelola memori tersebut. Bagian memori mana yang digunakan? Mana yang gratis? Di C, misalnya, Anda memiliki fungsi malloc(n) yang menemukan ruang memori n byte berturut-turut. Fungsi semacam ini juga disebut "alokator". 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 juga dimensi gambar output) sebelum menjalankan modul WebAssembly. Di sini, kita melihat peluang: Biasanya, kita akan meneruskan buffer RGBA gambar input sebagai parameter ke fungsi WebAssembly dan menampilkan gambar yang diputar sebagai nilai yang ditampilkan. Untuk menghasilkan nilai yang ditampilkan tersebut, 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 menempatkan gambar input ke dalam memori WebAssembly menggunakan JavaScript, jalankan modul WebAssembly untuk menghasilkan gambar kedua yang dirotasi, lalu gunakan JavaScript untuk membaca kembali hasilnya. Kita bisa pergi tanpa menggunakan manajemen memori sama sekali!

Dimanjakan dengan pilihan

Jika melihat fungsi JavaScript asli yang ingin kita jadikan milik WebAssembly, Anda dapat melihat bahwa kode tersebut benar-benar merupakan kode komputasi tanpa API khusus JavaScript. Karena itu, porta kode ini ke bahasa apa pun harus mudah dilakukan. 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 manajemen memori?

C dan Emscripten

Emscripten adalah compiler C untuk target WebAssembly. Tujuan 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 merupakan sifat dari 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 8 bit yang tidak ditandatangani (atau byte). 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 buffer RGBA gambar yang ingin diurutkan 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 yang tidak ditandatangani. 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 mem-port 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 glue yang disebut c.js dan modul wasm yang disebut c.wasm. Perhatikan bahwa modul wasm di-gzip menjadi hanya ~260 Byte, sedangkan kode lem berukuran sekitar 3,5 KB setelah gzip. Setelah mengutak-atik, kami dapat membuang kode glue dan membuat instance modul WebAssembly dengan vanilla API. 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 yang beragam, tanpa runtime, dan model kepemilikan yang menjamin keamanan memori dan keamanan thread. Rust juga mendukung WebAssembly sebagai fitur inti dan tim Rust telah menyumbangkan banyak alat yang sangat baik untuk 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 yang ada di C. Seperti di C, kita perlu membuat irisan yang menggunakan alamat awal. Hal ini bertentangan dengan model keamanan memori yang diterapkan Rust. Jadi, untuk melakukannya, 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,6KB dengan sekitar 100 byte kode lem (keduanya setelah gzip).

AssemblyScript

AssemblyScript adalah project yang cukup muda yang bertujuan untuk menjadi compiler TypeScript-to-WebAssembly. Namun, penting untuk diperhatikan bahwa class ini tidak hanya menggunakan TypeScript. AssemblyScript menggunakan sintaksis yang sama dengan TypeScript, tetapi mengganti library standar dengan sendirinya. Library standar mereka membuat model kemampuan WebAssembly. Artinya, Anda tidak dapat begitu saja mengompilasi TypeScript yang ada di WebAssembly, tetapi ini berarti bahwa 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(), cukup mudah untuk mem-port kode ini 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 tanpa kode lem. Modul ini hanya berfungsi dengan WebAssembly API biasa.

Forensik WebAssembly

7.6KB Rust sangat besar jika dibandingkan dengan 2 bahasa lainnya. Ada beberapa alat pada ekosistem WebAssembly yang dapat membantu Anda menganalisis file WebAssembly (terlepas dari bahasa yang digunakan untuk membuat) dan memberi tahu Anda apa yang terjadi serta membantu memperbaiki situasi.

Berkelok-kelok

Twiggy adalah alat lain dari tim WebAssembly Rust yang mengekstrak banyak data bermanfaat dari modul WebAssembly. Alat ini tidak khusus untuk 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 alokasi. 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 merasa 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. Kode ini mengambil modul WebAssembly dan mencoba mengoptimalkannya untuk ukuran dan performa hanya berdasarkan bytecode. Sebagian alat seperti Emscripten sudah menjalankan alat ini, sebagian 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 mengurangi beberapa byte lagi untuk menyisakan total 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 sepenuhnya, sehingga menghapus kode alokasi 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, modul ini cukup kecil untuk dianggap ringan.

Performa

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

Cara mengukur tolok ukur

Meskipun WebAssembly merupakan format bytecode tingkat rendah, format ini 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 akan mengamati bagian mana yang sering digunakan dan mengirimkannya melalui compiler yang lebih dioptimalkan 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 pengoptimalan. Hal ini penting untuk diingat saat 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 sekali dan membuat keputusan berdasarkan angka dari satu proses 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 bandingkan per browser, pada grafik kedua kita bandingkan per bahasa yang digunakan. Harap diperhatikan bahwa saya memilih skala waktu logaritmik. Penting juga bahwa semua benchmark menggunakan gambar pengujian 16 megapiksel dan mesin host yang sama, kecuali untuk satu browser, yang tidak dapat dijalankan pada mesin yang sama.

Tanpa menganalisis grafik ini terlalu banyak, jelas bahwa kami telah memecahkan masalah performa awal: Semua modul WebAssembly berjalan dalam ~500 md atau kurang. Hal ini mengonfirmasi apa yang kami siapkan di awal: WebAssembly memberi Anda performa yang dapat diprediksi. Apa pun bahasa yang kita pilih, varians antara browser dan bahasa minim. Tepatnya: Simpangan standar JavaScript di semua browser adalah ~400 md, sedangkan standar deviasi semua modul WebAssembly di semua browser adalah ~80 md.

Upaya

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

AssemblyScript tidak lancar. Dengan WebAssembly, Anda tidak hanya dapat menggunakan TypeScript untuk menulis WebAssembly, sehingga peninjauan kode menjadi sangat mudah bagi kolega saya, tetapi juga menghasilkan modul WebAssembly bebas lem yang berukuran sangat kecil dengan performa yang baik. Alat dalam ekosistem TypeScript, seperti prettier dan tslint, mungkin hanya 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 glue code dan menguranginya hanya dengan kebutuhan dasar, ukuran total (modul WebAssembly + kode glue) akan menjadi cukup besar.

Kesimpulan

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

Grafik perbandingan

Membandingkan kompromi ukuran modul / performa dari berbagai bahasa yang kami gunakan, pilihan terbaik sepertinya adalah C atau AssemblyScript. Kami memutuskan untuk mengirimkan Rust. Ada beberapa alasan untuk keputusan ini: Sejauh ini, semua codec yang dikirimkan di Squoosh 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 ini tidak semahal 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 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 secara manual. Namun, itu tidak boleh mencegah Anda menggunakan apa yang paling nyaman bagi Anda.

Itulah sebabnya: AssemblyScript telah menjadi penemuan yang luar biasa. Platform ini memungkinkan developer web menghasilkan modul WebAssembly tanpa harus mempelajari bahasa baru. Tim AssemblyScript sangat responsif dan aktif berupaya meningkatkan kualitas 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 sangat bagus, yang berisi bagian tentang cara 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 {i>gzip<i}. Untuk mengetahui detailnya, lihat PR yang saya buka di Squoosh.

Terima kasih banyak kepada Ashley Williams, Steve Klabnik, Nick Fitzgerald, dan Max Graey atas semua bantuannya dalam perjalanan ini.