Мікробенчмарк порадив мені зробити навпаки
Я роблю proof-of-concept системи плагінів на WebAssembly Component Model:
плагіни компілюються в .wasm один раз, Axum-хост завантажує їх на старті, і
кожен оголошує свої HTTP-, WebSocket- та SSE-маршрути через OpenAPI-специфікацію.
Вони працюють у пісочниці Wasmtime з лімітами палива й можуть звертатися до
власних таблиць через Diesel — запитами, що перетинають межу WASM.
Найцікавішим виявилося не те, як це запрацювало. А зміна продуктивності, яку я зробив, виміряв — і зрозумів навпаки.
Саме налаштування
Пул-алокатор Wasmtime може тримати шматок лінійної пам'яті плагіна резидентним
між інстанціюваннями, замість повертати його ОС. Перемикач називається
linear_memory_keep_resident. Тримати пам'ять теплою звучить очевидно добре:
наступне інстанціювання перевикористає сторінку, яка вже змаплена й обнулена.
Я поставив 2 МіБ і виміряв покращення на 23%. Це число було справжнім. І водночас марним — через те, як саме я вимірював.
Що насправді показувало це число
Однопотоковий цикл, який раз за разом інстанціює один плагін, — це майже
найкращий випадок для резидентної пам'яті. Кожне знищення платить memset, щоб
очистити ділянку, а кожне наступне інстанціювання одразу перевикористовує ту
саму теплу ділянку. У циклі витрата робиться один раз і одразу ж окупається.
Під реальною конкурентністю розмін обертається. memset на кожному знищенні —
це робота на гарячому шляху, а коли живих інстансів кілька, прогріта ділянка
часто дістається не тому запиту, що йде наступним. Ви замінили один madvise,
який ядро робить ліниво й дешево, на два мегабайти записів у пам'ять на кожен
запит.
Заміри при 16 одночасних з'єднаннях на один маршрут плагіна:
keep_resident |
CPU на запит | Пропускна здатність |
|---|---|---|
| 0 | 370 мкс | ~10k RPS |
| 64 КіБ | 379 мкс | ~10k RPS |
| 256 КіБ | 431 мкс | 9,2k RPS |
| 1 МіБ | 810 мкс | 5,5k RPS |
На 2 МіБ це коштувало приблизно половини пропускної здатності. Налаштування, яке було на 23% швидшим в один потік, виявилося дворазовою регресією під тим навантаженням, яке система насправді отримує.
Помилка, що ховалася за цим
Під час замірів я знайшов гірше. У пулі total_memories і total_tables
лишалися на 64, а це число обмежує одночасні інстанси, а не загальну
кількість. Приблизно після 32 одночасних з'єднань із keep-alive запити почали
падати.
Падали вони як 502 Bad Gateway із passthrough-хендлера — без жодного рядка в
логах. Тобто симптом такий: пропускна здатність перестає рости, і єдиний доказ
цього є у виводі навантажувача, а не в моєму. Я читав числа RPS як насичення,
хоча частково це були помилки.
Два висновки, і другий я вивчаю знову й знову:
- Ліміт місткості — це теж режим відмови. Якщо в нього можна впертися, потрібен лог саме в точці, де в нього впираються, а не лише опис ліміту в документації.
- Шлях помилки, який повертає статус і нічого не пише в лог, — це яма, у яку ви впадете саме тоді, коли вимірюєте щось інше.
Зміна, яка не дала нічого
Тим самим заходом я прибрав static ROUTER: LazyLock<_> усередині плагінів.
Виглядало як кеш. Ним воно бути не могло: кожен HTTP-виклик отримує свіжий
інстанс, тож ледачий ініціалізатор і так виконувався на кожен запит, а лок не
кешував нічого між викликами.
Заміна на звичайну функцію виміряла те саме, до мікросекунди. Я все одно залишив зміну. Код, який заявляє про кеш, котрого не може існувати, гірший за код, який не заявляє нічого: наступна людина, яка це читатиме — включно зі мною через пів року, — будуватиме своє розуміння системи на цій заяві.
Що я сказав би собі раніше
Міряйте під тією конкурентністю, яку очікуєте, а не під тією, яку легко налаштувати. Однопотокові цикли чудово знаходять алгоритмічне марнотратство і активно вводять в оману в усьому, що стосується перевикористання пам'яті, поведінки алокатора чи конкуренції — тобто в більшості того, чим займається хост-процес.
І коли результат виглядає як чиста перемога, перевірте, чого він коштує на іншому кінці того параметра, який ви не змінювали.
- Rust
- WebAssembly
- Wasmtime
- Продуктивність