Стандартные решения проблемы: докинуть серверов, все переписать с 0, оптимизировать уже написанное.
Давайте попробуем пойти путем оптимизации и разобраться, как можно найти и улучшить слабые места приложения. А быть может ускориться не трогая ни строчки кода :)
Всех заинтересованных добро пожаловать под кат!
Для начала определимся с методикой тестирования производительности. Нас будет интересовать количество обслуженных запросов за 1 секунду: rps.
Запускать будем приложение в режиме 1 воркера (1 процесса), замеряя производительность старого кода и кода с оптимизациями — абсолютная производительность не важна, важна сравнительная производительность.
В типичном приложении с множествами разных роутов логично сначала найти самые загруженные запросы, на обработку которых тратится большая часть времени. Утилы вида request-log-analizer или множество подобных позволят извлечь эту информацию из логов.
С другой стороны, можно взять реальный список запросов и пулять их все (например с помощью yandex-tank-а) — получим достоверный профиль нагрузки.
Но делая множество итераций оптимизации кода, куда удобнее использовать более простой и быстрый инструмент и один конкретный тип запросов (а после оптимизации одного запроса изучать следующий, и т.д.). Мой выбор — wrk. Тем более что в моем случае количество роутов не велико — проверить все по одному не сложно.
Сразу надо оговорится, что в плане блокирующих запросов, ожидания БД и т.п. приложение уже оптимизировано, все упирается в cpu: при тестах воркер потребляет 100% cpu.
На продашен серверах используется node.js версии 6 — с неё и начнем:
Requests/sec: 1210
Пробуем на 8й ноде:
Requests/sec: 2308
10я нода:
Requests/sec: 2590
Разница очевидна. Ключевую роль тут играет обновление версии v8 — множество плохо оптимизирующегося v8 кода осталось в прошлом. И чтобы не бороться с ветряными мельницами исчезнувшими в node.js v8 — лучше сразу обновиться, а потом уже заниматься оптимизацией кода.
Переходим собственно к поиску узких мест: на мой взгляд, лучший инструмент для этого — flamegraph. И с появлением проекта 0x получить flamegraph стало очень просто — запускам 0x вместо node: 0x -o ваш_скрип.js, делаем тест, останавливаем скрипт, смотрим результат в браузере.
Примерно так выглядит flamegraph тестируемого кода до оптимизаций:
![](https://habrastorage.org/webt/xt/ye/is/xtyeisyslhyuftd9bipdf-fvrbw.jpeg)
Внизу фильтры, оставляем app, deps — только код приложения и сторонних модулей.
Чем шире полоска — тем больше времени потрачено на выполнения этой функции (включая вложенные вызовы).
Разбираться будем с центральной, самой большой частью.
В первую очередь подсвечиваем неоптимизированные функции. У меня в приложении таких нашлось немного.
Далее, верхние функции — типичные кандидаты на оптимизацию. Остальные же функции выстроились горкой с относительно равномерными ступеньками — каждая функция вкладывает небольшую долю задержек, явного лидера нет.
Дальше возможен простой алгогритм действий: оптимизировать самые широкие функции, переходя от одной к другой. Но я выбрал другой подход: оптимизировать начиная от точки входа в приложения (обработчик запроса в http.createServer). В конце исследуемой функции вместо вызова следующих функций я завершаю обработку запроса, отвечая ответом-пустышкой, и изучаю производительность именно этой функции. После её оптимизации ответ-пустышка перемещается дальше по стеку вызовов к следующей функции и т.д.
Удобное следствие такого подхода: можно видеть rps в идеальных условиях (при работающей только одной стартовой функцией rps близок к максимальному rps-у hellow world node.js приложения), и при дальнейшем перемещении заглушки-ответа вглубь приложения наблюдать вклад исследуемой функции в падение производительности в rps-ах.
Итак, оставляем только стартовую функцию, получаем:
Requests/sec: 16176
![](https://habrastorage.org/webt/4b/mf/ao/4bmfao-9njwfirrg9couylmewvs.jpeg)
Подключая фильтры core, v8 можно увидеть, что практически вся исследуемая функция состоит из отправки ответа, логгирования и других слабо оптимизируемых вещей — едем дальше.
Переходим к следующей функции:
Requests/sec: 16111
Ничего не изменилось — погружаемся дальше:
Requests/sec: 13330
![](https://habrastorage.org/webt/rv/g9/gl/rvg9gl0bb3g6xdm3zdrj1iuxrrg.jpeg)
Наш клиент! Видно что задействованная функция getByUrl занимает значимую часть стартовой функции — что хорошо коррелирует с проседанием rps.
Смотрим внимательно что в ней происходит (включаем core, v8):
Много чего происходит… курим код, оптимизируем:
for (var i in this.data) {
if (this[i]._options.regexp_obj.test(url)) return this[i];
}
return null;
превращаем в
let result = null;
for (let i=0; i<this.length && !result; i++) {
if (this[i]._options.regexp_obj.test(url)) result = this[i];
}
В данном случае простой for значительно быстрее for..in
Получаем Requests/sec: 16015
![](https://habrastorage.org/webt/4p/gt/5i/4pgt5is8dg5l6jkhsexmr5psgjs.jpeg)
Визуально функция «сдулась» и занимает значительно меньшую долю от стартовой функции.
В детальной информации по функции так же все значительно упростилось:
Идем дальше, к следующей функции
Requests/sec: 13316
![](https://habrastorage.org/webt/oy/jc/yq/oyjcyq5ydbbivg1szldwj684g9q.jpeg)
В этой функции много array функций и, несмотря на существенное ускорение в последних версиях node.js, они все еще медленней простых циклов: меняем [].map и filter. на обычный for и получаем
Requests/sec: 15067
![](https://habrastorage.org/webt/y2/xh/tn/y2xhtnpfepkwinx7fash4k2cmeg.jpeg)
И так раз за разом, для каждой следующей функции.
Еще несколько пригодившихся оптимизаций: для хешей с динамически изменяемым набором ключей new Map() может быть на 40% быстрее обычного {};
Math.round(el*100)/100 в 2 раза быстрее чем toFixed(2).
В flamegraph-е для core и v8 функций можно увидеть как и малопонятные записи, так и вполне говорящие StringPrototypeSplit или v8::internal::Runtime_StringToNumber, и, если это значимая часть выполнения кода, попытаться оптимизировать, например просто переписать код, не выполняющий эти операции.
Например, замена split на несколько вызовов indexOf и substring может давать двойной выигрыш в производительности.
Отдельная большая и сложная тема — jit оптимизация, а вернее деоптимизированные функции.
При наличии большой доли таких функций надо будет разбираться и с ними.
Тут может помочь вдумчивое изучение вывода node --trace_file_names --trace_opt_verbose --trace-deopt --trace_opt
Например, строчки вида
deoptimizing (DEOPT soft): begin 0x2bcf38b2d079 <JSFunction getTime… Insufficient type feedback for binary operation привели к строчке
return val >= 10? val: '0'+val;
Замена на
return (val >= 10? '': '0')+val;
исправила ситуацию.
Для старого v8 движка есть достаточно много информации по причинам и способах борьбы с деоптимизацией функций:
github.com/P0lip/v8-deoptimize-reasons — список,
www.netguru.co/blog/tracing-patterns-hinder-performance — разбор типовых причин,
www.html5rocks.com/en/tutorials/speed/v8 — про оптимизации для v8, думаю справедливо и для текущего движка v8.
Но многие из проблем уже не актуальны для нового v8.
Так или иначе, после всех оптимизаций удалось получить Requests/sec: 9971, т.е. ускорится примерно в 2 раза за счет перехода на свежую версию node.js, и еще в 4 раза за счет оптимизации кода.
Надеюсь, этот опыт будет полезен кому-нибудь еще.
Комментарии (11)
wtpltd
20.04.2019 03:06А вы не могли бы пояснить самую первую диаграмму?
Такое впечатление, что горизонтальную гистограмму положили поверх вертикальной. А про что вертикальная я вообще не понял.sh84 Автор
20.04.2019 09:27Все диаграммы одинаковы по сути, на первой только полный стек вызовов обработки запроса с множеством вложенных функций, а на последующих — значительно меньший.
Каждый прямоугольник — это функция, ширина которого пропорциональна времени проведенному в этой функции при работе приложения. Соответственно самая широкая нижняя прямоугольник-функция — точка входа. Координата по горизонтали не имеет значения.
Вверх надстраиваются функции вызывающиеся из нижний функции. Т.е. по вертикали показывается вложенность вызовов функций.
1c80
20.04.2019 10:46+1Спасибо, за статью, она линий раз демонстрирует, что добиться повышения производительности можно и простым рефакторингом кода, просто времени много надо,
обычно заказчику говорят, Ваш сервер устарел, нужно более мощное оборудование.
Но вот если себе делаешь, то тут конечно можно и попотеть.
Интересно есть ли где нибудь графики скорости работы однотипных функций?
такое могло бы сэкономить кучу времени, так то на уровне подсознания понятно, что простой цикл, с минимумом разименовываний полей будет работать быстрее, но вот насколько и стоит ли игра свеч, так как читаемость кода явно ухудшается.
justboris
20.04.2019 14:25На продашен серверах используется node.js версии 6
Очень вовремя вы съехали с версии, которая через уже через 10 дней (30 апреля) перестанет поддерживаться.
Я так понимаю, далее по тексту показываются измерения в 10й версии? Потому что все эти приемчики могут оказаться бесполезными для других версий.
justboris
20.04.2019 14:26В функции getByUrl после оптимизации вы зачем-то убрали ранний return из цикла, так что код теперь пробегает весь список целиком, даже если совпадение нашлось раньше. Зачем?
sh84 Автор
20.04.2019 14:43i<a.length && !result — эквивалент раннего return-а.
С точки зрения производительности принципиальной разницы с return-ом внутри быть не должно, и насколько помню, такой вариант и появился в процессе проверки этой теории.
В любом случае главное тут замена цикла for..in на обычный for.justboris
20.04.2019 22:01+1а есть ли прирост от того, что вы заменили return на такую проверку?
если нет, то я бы оставил return, потому что оптимизации оптимизациями, но и понятность кода важна тоже.sh84 Автор
22.04.2019 10:36Прироста нет.
Пусть остается как есть, что бы было понятно очем мы говорили.
Комментарий про то, что смысл правки в другом виде цикла я добавил.
ReklatsMasters
Могу ещё порекомендовать использовать стандартную линуксовую утилиту
perf
. Несколько раз хорошо помогала в разы ускорять приложение.Это происходит из-за того, что резолв в объекте происходит медленнее, чем просто доступ по ключу. У меня была похожая ситуация, только динамические ключи объекта были именами функциями. И ускорить такие вызовы смог с помощью генерируемых функций,
generate-function
.Ещё могу добавить, что операции с созданием / пересозданием буферов очень медленные и стоит их избегать, если нужна высокая производительность.
Odrin
Не только из-за этого. Автор пишет именно про «для хешей с динамически изменяемым набором ключей», а добавление/удаление ключей в объекте приводит к изменению object shape, что в свою очередь является деоптемизацией.