Оптимизация node.js приложения

    Дано: старое http node.js приложение и возросшая нагрузка на него.

    Стандартные решения проблемы: докинуть серверов, все переписать с 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 тестируемого кода до оптимизаций:


    Внизу фильтры, оставляем app, deps — только код приложения и сторонних модулей.

    Чем шире полоска — тем больше времени потрачено на выполнения этой функции (включая вложенные вызовы).

    Разбираться будем с центральной, самой большой частью.

    В первую очередь подсвечиваем неоптимизированные функции. У меня в приложении таких нашлось немного.

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

    Дальше возможен простой алгогритм действий: оптимизировать самые широкие функции, переходя от одной к другой. Но я выбрал другой подход: оптимизировать начиная от точки входа в приложения (обработчик запроса в http.createServer). В конце исследуемой функции вместо вызова следующих функций я завершаю обработку запроса, отвечая ответом-пустышкой, и изучаю производительность именно этой функции. После её оптимизации ответ-пустышка перемещается дальше по стеку вызовов к следующей функции и т.д.

    Удобное следствие такого подхода: можно видеть rps в идеальных условиях (при работающей только одной стартовой функцией rps близок к максимальному rps-у hellow world node.js приложения), и при дальнейшем перемещении заглушки-ответа вглубь приложения наблюдать вклад исследуемой функции в падение производительности в rps-ах.

    Итак, оставляем только стартовую функцию, получаем:

    Requests/sec: 16176



    Подключая фильтры core, v8 можно увидеть, что практически вся исследуемая функция состоит из отправки ответа, логгирования и других слабо оптимизируемых вещей — едем дальше.

    Переходим к следующей функции:

    Requests/sec: 16111
    Ничего не изменилось — погружаемся дальше:
    Requests/sec: 13330


    Наш клиент! Видно что задействованная функция 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



    Визуально функция «сдулась» и занимает значительно меньшую долю от стартовой функции.
    В детальной информации по функции так же все значительно упростилось:

    Идем дальше, к следующей функции

    Requests/sec: 13316



    В этой функции много array функций и, несмотря на существенное ускорение в последних версиях node.js, они все еще медленней простых циклов: меняем [].map и filter. на обычный for и получаем

    Requests/sec: 15067



    И так раз за разом, для каждой следующей функции.

    Еще несколько пригодившихся оптимизаций: для хешей с динамически изменяемым набором ключей 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

      0

      Могу ещё порекомендовать использовать стандартную линуксовую утилиту perf. Несколько раз хорошо помогала в разы ускорять приложение.


      для хешей с динамически изменяемым набором ключей new Map() может быть на 40% быстрее обычного {};

      Это происходит из-за того, что резолв в объекте происходит медленнее, чем просто доступ по ключу. У меня была похожая ситуация, только динамические ключи объекта были именами функциями. И ускорить такие вызовы смог с помощью генерируемых функций, generate-function.


      Ещё могу добавить, что операции с созданием / пересозданием буферов очень медленные и стоит их избегать, если нужна высокая производительность.

        0
        Это происходит из-за того, что резолв в объекте происходит медленнее, чем просто доступ по ключу.

        Не только из-за этого. Автор пишет именно про «для хешей с динамически изменяемым набором ключей», а добавление/удаление ключей в объекте приводит к изменению object shape, что в свою очередь является деоптемизацией.
        0
        А вы не могли бы пояснить самую первую диаграмму?
        Такое впечатление, что горизонтальную гистограмму положили поверх вертикальной. А про что вертикальная я вообще не понял.
          0
          Все диаграммы одинаковы по сути, на первой только полный стек вызовов обработки запроса с множеством вложенных функций, а на последующих — значительно меньший.
          Каждый прямоугольник — это функция, ширина которого пропорциональна времени проведенному в этой функции при работе приложения. Соответственно самая широкая нижняя прямоугольник-функция — точка входа. Координата по горизонтали не имеет значения.
          Вверх надстраиваются функции вызывающиеся из нижний функции. Т.е. по вертикали показывается вложенность вызовов функций.
          +1
          Спасибо, за статью, она линий раз демонстрирует, что добиться повышения производительности можно и простым рефакторингом кода, просто времени много надо,
          обычно заказчику говорят, Ваш сервер устарел, нужно более мощное оборудование.
          Но вот если себе делаешь, то тут конечно можно и попотеть.
          Интересно есть ли где нибудь графики скорости работы однотипных функций?
          такое могло бы сэкономить кучу времени, так то на уровне подсознания понятно, что простой цикл, с минимумом разименовываний полей будет работать быстрее, но вот насколько и стоит ли игра свеч, так как читаемость кода явно ухудшается.
            0
            На продашен серверах используется node.js версии 6

            Очень вовремя вы съехали с версии, которая через уже через 10 дней (30 апреля) перестанет поддерживаться.


            Я так понимаю, далее по тексту показываются измерения в 10й версии? Потому что все эти приемчики могут оказаться бесполезными для других версий.

              0
              Да, все в 10й.
              0

              В функции getByUrl после оптимизации вы зачем-то убрали ранний return из цикла, так что код теперь пробегает весь список целиком, даже если совпадение нашлось раньше. Зачем?

                0
                i<a.length && !result — эквивалент раннего return-а.
                С точки зрения производительности принципиальной разницы с return-ом внутри быть не должно, и насколько помню, такой вариант и появился в процессе проверки этой теории.
                В любом случае главное тут замена цикла for..in на обычный for.
                  +2
                  а есть ли прирост от того, что вы заменили return на такую проверку?

                  если нет, то я бы оставил return, потому что оптимизации оптимизациями, но и понятность кода важна тоже.
                    0
                    Прироста нет.
                    Пусть остается как есть, что бы было понятно очем мы говорили.
                    Комментарий про то, что смысл правки в другом виде цикла я добавил.

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое