Я зовсім неправильно розумів WASM! 🤯

Коротко

  • WASM чудово підходить як для фронтенду, так і для бекенду, і не лише для прискорення JavaScript у браузері.
  • WASM на бекенді працює по-іншому, ніж Foreign Function Interface (FFI). WASM спроектовано так, щоб працювати швидше та ефективніше.
  • Швидкість WASM забезпечується завдяки низькорівневому бінарному формату, простій моделі пам'яті та попередньому компілюванню (ahead-of-time compilation). Це мінімізує накладні витрати, наближаючи продуктивність до рівня рідного коду.
  • Я використав Rust і WASM для оптимізації генерації ULID в wa-ulid. Результат виявився 40 разів швидшим за JavaScript-версію.
  • Поточні файли WASM більші за JavaScript, що може бути складністю. Але з розвитком інструментів та методів оптимізації WASM стане більш практичним для застосувань на бекенді та фронтенді.

Вступ

Як розробник, я часто проходжу через різні етапи при вивченні нових технологій, що нагадує цикл захоплення Gartner (Gartner Hype Cycle)...

Цикл захоплення Gartner

Цей цикл показує типовий шлях прийняття нових технологій. У цьому дописі я хочу пояснити, як я перейшов від скептицизму до захоплення, особливо щодо використання WASM для покращення продуктивності бекенду.

pic

Цикл захоплення Gartner

WASM — це низькорівневий формат інструкцій. Він спроектований як ціль компіляції для мов, таких як C, C++ та Rust. Основна мета — забезпечити високу продуктивність веб-додатків. Однак все більше його починають використовувати і на серверній стороні, коли важлива продуктивність.

Мій шлях з WASM був зі своїми підйомами і спадами. Почалося все з надмірних очікувань, потім прийшла розчарування, але закінчилося це твердим розумінням та практичними застосуваннями.

Початкові непорозуміння

Коли я вперше почув про WASM, мої надії були дуже високими. Я думав, що WASM дозволить нам безперешкодно інтегрувати складні обчислення в веб-браузери.
Це здалося схожим на те, як FFI дозволяє мовам високого рівня виконувати машинний код.

Що таке FFI?

FFI дозволяє коду однієї мови безпосередньо викликати код іншої мови. Це використовується, коли важлива продуктивність, і певна логіка реалізована на низькорівневих мовах, таких як C чи Rust. Цей низькорівневий код потім викликається з мов вищого рівня, таких як Python або JavaScript.

Я думав, що WASM подібний до FFI, просто спосіб виконувати машинний код у браузері. Це мало сенс, оскільки WASM компілює мови високого рівня в низькорівневий бінарний формат. Але я не врахував унікальну архітектуру і обмеження WASM.

Порівняння WASM і FFI

Погляд на WASM як на FFI змусив мене пропустити те, що робить WASM відмінним від традиційного машинного коду. У FFI часто спостерігається значні накладні витрати при перемиканні між мовою хоста і чужою функцією. Переміщення даних між різними структурами пам'яті також є витратним.

Перевірка реальності

Досліджуючи WASM далі, я почав помічати розбіжності між моїми початковими очікуваннями та реальністю його використання.

Перші кроки з WASM і Rust

Я почав експериментувати з WASM, використовуючи wasm-bindgen, інструмент, який допомагає модулям WASM і JavaScript працювати разом. Мій перший приклад був простим:

use wasm_bindgen::prelude::*;  

#[wasm_bindgen]  
pub fn add(a: u32, b: u32) -> u32 {  
 a + b  
}

Використовуючи wasm-pack з оптимізацією на етапі лінкування (LTO), ця базова функція додавання скомпільована в малесенький модуль WASM розміром 214 байтів. Спочатку це здавалося доказом того, що WASM може забезпечити компактний і ефективний код.

Виявлення формату WAT

Щоб краще зрозуміти, як працює такий маленький шматок коду, я подивився на версію WAT (WebAssembly Text Format). WAT — це читабельна версія бінарних файлів WASM.
WAT — це читабельна версія бінарних файлів WASM. Це необхідно для налагодження та оптимізації додатків WASM. Ось WAT для функції add:

(module  
 (type (;0;) (func (param i32 i32) (result i32)))  
 (func (;0;) (type 0) (param i32 i32) (result i32)  
 local.get 0  
 local.get 1  
 i32.add)  
 (memory (;0;) 17)  
 (export "memory" (memory 0))  
 (export "add" (func 0)))

Цей лаконічний формат демонструє ефективність WASM для простих обчислювальних задач — без зайвих накладних витрат, тільки основні операції для виконання функції.

Вплив додавання складності

Потім я змінив приклад, додавши операції зі строками, щоб побачити вплив на розмір модуля:

use wasm_bindgen::prelude::*;  

#[wasm_bindgen]  
pub fn add(a: u32, b: u32) -> u32 {  
 let a = a.to_string().parse::<u32>().unwrap();  
 let b = b.to_string().parse::<u32>().
unwrap();  
 return a + b;  
}

Незважаючи на те, що я виконував ті самі математичні операції, ця версія створила набагато більший модуль WASM розміром 14,5 КБ. Файл WAT зріс до понад 7 126 рядків, що відображає додану складність і накладні витрати при роботі зі строками.

pic

1/3 частина файлу WAT після додавання маніпуляцій зі строками

Конструктор WebAssembly.Instance може синхронно компілювати лише модулі розміром до 4 КБ. Більші модулі повинні компілюватися асинхронно. Але для мене це здалося неможливим — утримувати файли WASM в межах такого ліміту.

Розчарування

Основною проблемою став різкий ріст розміру модуля WASM при додаванні таких можливостей, як маніпуляції зі строками. Збільшення розміру файлу суперечило обіцянці WASM бути легким і ефективним форматом.

Оптимізація розміру модуля WASM

Щоб вирішити ці проблеми, я досліджував способи оптимізації розміру модуля WASM. Ось кілька стратегій для мінімізації розміру застосунків WASM:

  • Уникнення панік: Обробка панік у Rust додає накладні витрати. Використання типів Option і Result дозволяє ефективно управляти помилками і уникати зайвих витрат на паніки.
  • Обмеження використання строк: Динамічні операції зі строками значно збільшують розмір модуля WASM. Використання цілих чисел або фіксованих типів даних дозволяє зберігати модулі компактними.
  • Оптимізація на етапі лінкування (LTO): Включення LTO в компілятор Rust зменшує розмір скомпільованого WASM, видаляючи невикористовуваний код і оптимізуючи код між crate.
  • Ручне видалення мертвого коду (Tree Shaking): Хоча автоматичне видалення мертвого коду обмежене в конвеєрі Rust-to-WASM, ручне забезпечення того, щоб включались лише необхідні функції та залежності, допомагає уникнути надмірних витрат.
    Незважаючи на ці зусилля, вроджені труднощі іноді здавались непереможними, особливо коли йшлося про складні типи даних та операції, характерні для задач вищого рівня програмування.

Динамічні мови в WASM

Проблеми WASM не є унікальними для Rust. Інші мови, зокрема динамічні, як Python, стикаються з ще більшими труднощами. Щоб зрозуміти чому, розглянемо компіляцію динамічної мови до WASM:

  1. Компіляція інтерпретатора: Для Python весь інтерпретатор повинен бути скомпільований в WASM, а не лише код користувача. Це включає всі вбудовані функції та бібліотеки, які підтримує мова.
  2. Виконання коду: Запуск Python-коду, скомпільованого до WASM, означає виконання інтерпретатора в межах іншого інтерпретатора. Це додає значні накладні витрати і може призвести до великих бінарних файлів WASM.

pic

Транспайлер Python компілюється в WASM, щоб транспайлити вихідний код Python у WASM для виконання в середовищі виконання WASM.
Навіть для Go, мови з статичною типізацією і компільованою, мінімальний розмір файлу WASM складає 2 МБ, згідно з Go Programming Language Wiki.

Труднощі спільноти

Моє розчарування було відображенням ширших проблем у спільноті розробників. Багато статей описують подібні труднощі:

  • Запис після помилки Zaplib: В цій статті детально описано, як стартап вирішив відмовитися від WASM через відсутність поліпшень продуктивності та складність розробки.
  • Tree-shaking, садівничо помилковий алгоритм: Тут підкреслюється недозрілість процесу tree-shaking у інструментальній ланцюжку WASM, що є критичним процесом для зменшення розміру фінального бінарного файлу шляхом видалення невикористовуваного коду.

Ці досвіди з спільноти підкреслили труднощі використання WASM в його поточному стані і стали суворим контраргументом до початкового захоплення.

Поворотний момент

Під час того, як я долав своє розчарування з WASM, важливим моментом стало те, коли я знайшов бібліотеку h3, розроблену компанією Uber. Ця бібліотека, що включає реалізації на кількох мовах (C, Python, Java, JavaScript), а також h3-js, використовує Emscripten для мосту між JavaScript і WASM, скомпільованим з C.

Розуміння h3

h3 призначена для геопросторової індексації. Вона надає спосіб індексації координат у гексаґональну сітку. Ця система особливо корисна для застосунків з великими геопросторовими наборами даних. Однією з функцій, яку я часто використовую, є latLngToCell, яка перетворює координати широти та довготи в ідентифікатор гексаґональної клітинки.

Порівняння продуктивності

Для оцінки продуктивності h3-js я порівняв C-реалізацію з JavaScript-версією, скомпільованою в WASM. На щастя, їхні репозиторії вже містили програми для бенчмаркінгу.
Ось результати з мого локального MacBookPro на процесорі M2:

Дослідження можливостей WASM

Мотивований несподіваними результатами h3-js, я вирішив більше дослідити можливості WASM. Я порівняв його продуктивність з JavaScript та FFI, використовуючи обчислювальну задачу: Гіпотеза Коллатца.

[

GitHub - yujiosaka/wasm-and-ffi-performance-comparison-in-node: Порівняння продуктивності між Rust FFI та Rust, скомпільованим у WASM для гіпотези Коллатца.

github.com

Що таке гіпотеза Коллатца?

Гіпотеза Коллатца, також відома як проблема "3n + 1", є математичною гіпотезою, що включає послідовність, визначену таким чином:

  1. Почніть з будь-якого додатного цілого числа n.
  2. Якщо n парне, поділіть його на 2.
    3.
    Якщо n непарне, помножте його на 3 і додайте 1.
  3. Повторюйте процес, поки n не стане 1.

pic

Гіпотеза Коллатца, починаючи з n=3

Гіпотеза стверджує, що незалежно від початкового значення n, послідовність завжди врешті-решт досягне 1.

Гіпотеза Коллатца в JS, FFI та WASM

Для порівняння продуктивності я реалізував гіпотезу в чистому JavaScript, використовуючи FFI для виклику функції на Rust, а також безпосередньо в WASM. Вхідним значенням для n я взяв 670617279, яке потребує 986 кроків, щоб досягти 1.
- JavaScript

function collatzSteps(n) {  
 let counter = 0;  
 while (n !== 1) {  
 if (n % 2 === 0) {  
 n /= 2;  
 } else {  
 n = 3 * n + 1;  
 }  
 counter++;  
 }  
 return counter;  
 }
  • Rust (FFI) та Rust (WASM)
pub fn collatz_steps(mut n: u64) -> u64 {  
 let mut counter = 0;  
 while n != 1 {  
 if n % 2 == 0 {  
 n /= 2;  
 } else {  
 n = 3 * n + 1;  
 }  
 counter += 1;  
 }  
 return counter;  
 }

Більше деталей можна знайти в моєму репозиторії. Ось результати тестів на M2 MacBookPro:

Ці результати показали, що WASM може перевершити як нативний JavaScript, так і FFI, особливо для обчислювально інтенсивних завдань.

Глибоке занурення в продуктивність WASM

Після того, як я побачив вражаючу продуктивність WASM з h3-js та гіпотезою Коллатца, стало зрозуміло, що в WASM є більше, ніж я спочатку усвідомлював.

Чим WASM відрізняється від FFI

Ключ до розуміння ефективності WASM полягає в його дизайні як формату низькорівневих бінарних інструкцій. Він не тільки є незалежним від платформи, але й оптимізований для швидкості виконання та компактності, на відміну від FFI, де можуть виникати великі накладні витрати через маршалінг даних між контекстами виконання та обробку різних моделей пам'яті. Така архітектура мінімізує типові накладні витрати FFI завдяки наступному:

  • Управління пам'яттю є лінійним і єдиним: WASM використовує один суцільний блок пам'яті, що спрощує взаємодію з хост-середовищем. Це зменшує витрати, пов'язані з управлінням пам'яттю у традиційних налаштуваннях FFI.
  • Бінарний формат, оптимізований для виконання: Бінарний формат WASM спроектований для ефективного декодування та виконання сучасними JIT (Just-In-Time) компіляторами. Це дозволяє досягти продуктивності, наближеної до швидкості нативного машинного коду, без типових штрафів від інтерпретації під час виконання.
    ## Використання WASM на серверній стороні

Знахідки щодо бібліотеки h3-js та мої експерименти з гіпотезою Коллатца призвели до зміни поглядів на застосування WASM:

  • Серверна сторона важливіша за фронтенд: Хоча спочатку WASM розглядали як потенціал для веб-додатків, його сильні сторони особливо помітні в серверному середовищі та інших не браузерних контекстах, де часто використовуються ресурсоємні обчислення, такі як обробка даних, наукові розрахунки та реальний час кодування/декодування медіа.
  • Обчислення на краю мережі (Edge Computing): WASM ідеально підходить для застосувань обчислень на краю мережі, де запуск коду ближче до джерела даних може суттєво покращити час відгуку та знизити використання пропускної здатності.

Оптимізація генерації ULID за допомогою WASM

Одним з практичних застосувань, де я використав продуктивність WASM, була генерація Універсальних Унікальних Лексикографічно Сортувальних Ідентифікаторів (ULID). ULID виконують схожу функцію до UUID, але при цьому є сортуємими.
Вони складаються з компонента часової мітки та випадковості, закодованих для забезпечення унікальності та лексикографічної сортуємості. Це робить їх особливо корисними для розподілених систем, де порядок сортування та унікальність є критичними.

Приріст продуктивності в 40 разів

Перевівши існуючу реалізацію генерації ULID на JavaScript у Rust, скомпільований в WASM, я досяг значного приросту продуктивності — приблизно в 40 разів швидше, ніж оригінальна версія на JavaScript.

[

GitHub - yujiosaka/wa-ulid: Високопродуктивний генератор ULID (Універсально Унікальний Лексикографічно Сортувальний Ідентифікатор)…

Високопродуктивний генератор ULID (Універсально Унікальний Лексикографічно Сортувальний Ідентифікатор) з використанням WebAssembly, до…

github.com

](https://github.com/yujiosaka/wa-ulid?source=post_page-----e4bcab8d077c--------------------------------)

Цей перший етап перекладу був простим, але після цього я застосував більш вивірений підхід для подальшої оптимізації продуктивності.

Подальша оптимізація

Спочатку покращення продуктивності становило близько 10 разів. Однак завдяки кільком оптимізаціям у реалізації на Rust, я досяг покращення в 40 разів. Ось основні техніки, які сприяли цьому значному приросту продуктивності, хоча вони не є специфічними для WASM:

1. Використання ефективних структур даних

Оптимізація структур даних, які використовуються в реалізації, наприклад, використання векторів з попередньо виділеною ємністю замість динамічного розширення, дозволила мінімізувати алокації пам'яті і не допустити уповільнення через часті операції з пам'яттю.

// До  
String::new();  
// Після  
String::with_capacity(len);

2. Уникання непотрібних перетворень і алокацій пам'яті

Оригінальна реалізація на Rust містила непотрібні перетворення рядків і символів, що були обчислювально дорогими.
Оптимізуючи обробку даних та зменшуючи алокацію пам'яті, продуктивність значно покращилася. Наприклад, використання масивів байтів замість перетворення їх на рядки чи символи, коли це можливо, допомогло зменшити накладні витрати.

// До  
const ENCODING: &str = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";  
...  
let mut chars = Vec::with_capacity(len);  
for index in 0..len {  
 chars.push(ENCODING.chars().nth(index).unwrap());  
}  

// Після  
const ENCODING: &str = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";  
const ENCODING_BYTES: &[u8] = ENCODING.as_bytes();  
...  
let mut chars = Vec::with_capacity(len);  
for index in 0..len {  
 chars.push(ENCODING_BYTES[index] as char);  
}

2. Попереднє обчислення та кешування обчислень

Попереднє обчислення значень, які будуть використовуватися повторно в функціях, таких як степені довжини кодування, та їх кешування значно зменшило обчислювальне навантаження.
Це було особливо ефективно для функцій, таких як decode_time, де операції повторюються і є передбачуваними.

// До  
const ENCODING_LEN: usize = 32;  
const TIME_LEN: usize = 10;  
...  
for i in 0..TIME_LEN {  
 time += i as f64 * (ENCODING_LEN as u64).pow(index as u32) as f64;  
}  

// Після  
const ENCODING_LEN: usize = 32;  
const POWERS: [f64; 10] = [1.0, 32.0, ..., 35184372088832.0];  
...  
for i in 0..TIME_LEN {  
 time += i as f64 * POWERS[index];  
}

Висновок

Оптимізація генерації ULID за допомогою WASM є прикладом того, як розуміння та використання можливостей WASM можуть призвести до значного покращення продуктивності в практичних застосунках. Цей випадок — лише один з прикладів того, як WASM може ефективно використовуватися не тільки в бекенд-системах, де важливі продуктивність та ефективність, але також має значний потенціал для вебу, оскільки інструментальні ланцюги еволюціонують.
Наразі проблема великих розмірів бінарних файлів дещо обмежує використання WASM у фронтенд-застосунках, де важлива швидкість завантаження та виконання. Однак це не є постійним обмеженням, а радше поточним бар'єром. Як тільки інструментальні ланцюги WASM удосконалюватимуться в таких техніках, як tree shaking та оптимізація бінарних виходів, ми можемо очікувати значного зменшення цих розмірів.

Майбутнє WASM у вебі виглядає обнадійливо. Оскільки інструментальні ланцюги розвиваються і покращують свою здатність створювати менші та ефективніші бінарники, потенціал WASM для революціонізації веб-продуктивності та можливостей зростає. Це не лише покращить бекенд-застосунки, але й може радикально змінити спосіб, у який складні застосунки розгортаються та виконуються в браузерних середовищах, роблячи їх настільки ж ефективними та потужними, як і їхні нативні аналоги.

Перекладено з: I was understanding WASM all wrong! 🤯

Leave a Reply

Your email address will not be published. Required fields are marked *