До вмісту
fr0staman
Усі статті

Мікробенчмарк порадив мені зробити навпаки

3 хв читання

Я роблю 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 як насичення, хоча частково це були помилки.

Два висновки, і другий я вивчаю знову й знову:

  1. Ліміт місткості — це теж режим відмови. Якщо в нього можна впертися, потрібен лог саме в точці, де в нього впираються, а не лише опис ліміту в документації.
  2. Шлях помилки, який повертає статус і нічого не пише в лог, — це яма, у яку ви впадете саме тоді, коли вимірюєте щось інше.

Зміна, яка не дала нічого

Тим самим заходом я прибрав static ROUTER: LazyLock<_> усередині плагінів. Виглядало як кеш. Ним воно бути не могло: кожен HTTP-виклик отримує свіжий інстанс, тож ледачий ініціалізатор і так виконувався на кожен запит, а лок не кешував нічого між викликами.

Заміна на звичайну функцію виміряла те саме, до мікросекунди. Я все одно залишив зміну. Код, який заявляє про кеш, котрого не може існувати, гірший за код, який не заявляє нічого: наступна людина, яка це читатиме — включно зі мною через пів року, — будуватиме своє розуміння системи на цій заяві.

Що я сказав би собі раніше

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

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

Поділитися