Микрооптимизации производительности и JavaScript

https://docs.google.com/presentation/d/1_eLlVzcj94_G4r9j9d_Lj5HRKFnq6jgpuPJtnmIBs88/edit#slide=id.p

Недавно я поучаствовал в дискуссии в комментариях на Hexlet на тему «Что важнее — оптимизации производительности или качество кода?» Если вернуться чуть назад, то исходный вопрос звучал так — нужно ли использовать перебирающие методы массивов (т.е. Array.prototype.map(), Array.prototype.filter(), Array.prototype.reduce() и т.д.) или стоит остановиться на старом добром цикле for ввиду его явного превосходства в производительности?

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

Разберём по порядку.

Устаревающее знание о специфике поведения интепретаторов

JavaScript является интерпретируемым языком. Это значит, что при написании кода, мы никак не контролируем то, как будет выглядеть код в виде машинных инструкций. Это полностью зависит от движка, исполняющего наш JavaScript, и этих движков великое множество. Например, в Chrome наш код исполняется с помощью движка V8, в Safari это JavaScriptCore, в Firefox — SpiderMonkey. И это далеко не все. Более того, каждый движок имеет множество версий. И вот, что интересно — все браузерные движки являются конкурентами. Но как они могут конкурировать между собой за любовь пользователя? Кто-то скажет — поддержкой самых современных фишек EcmaScript! Но нет, на самом деле пользователям по большему счёту всё равно, насколько хорошо браузер что-то там поддерживает. Интерфейс приятный? Все сайты работают? И работают быстро?

Стоп. Всё должно работать быстро. Вот где секрет.

Каждый движок борется за то, чтобы один и тот же корректный JavaScript-код в одинаковых условиях (операционная система, количество ОЗУ, мощность процессора) работал в нём быстрее. Как это возможно? Как один и тот же код в, казалось бы, одинаковых условиях может исполняться с разной скоростью? Всё дело в оптимизациях.

За счёт мощнейших оптимизаций, происходящих во время выполнения кода, интерпретируемые языки достигают сравнимых (а порой и превосходящих) результатов с компилируемыми языками. Каждый такой движок содержит JIT(Just In Time)-компилятор, который анализирует исходный код и входные параметры, и генерирует оптимизированный машинный код под заданные условия.

Можем ли мы как-то повлиять на генерируемый код? Да. Мы можем:

И вот тут мы вступаем в серую зону. В наш код, который должен жить долго, завезено знание о том, как именно движки превращали эти JavaScript-инструкции в машинный код в какой-то конкретный момент времени.

Так что же, если нам не нужно писать максимально производительный код (что мы обсудим во второй части), то можно закрыть глаза на внутреннюю реализацию и писать что угодно? Есть ли разница между отказом от перебирающих методов массивов и отказом от неявных проверок на истинность объекта, отдавая предпочтение прямому сравнению с undefined?

    // Мы хотим убедиться, что объект существует,
// а движок должен догадаться, что лежит в
// переменной - ссылка на объект или примитив,
// и какого типа этот примитив
if (someObject) {}

// Мы явно говорим движку, что нам не важно,
// что лежит в переменной,
// главное - что она объявлена
if (someObject !== undefined) {}

// Совсем хардкор. Мы оптимизируем код
// используя знания о том, что
// undefined — это всего лишь поле в
// глобальном объекте. Движок должен
// убедиться, что оно не переопределено,
// а void 0 вернёт
// «чистый» undefined, который не надо
// дополнительно проверять. Нет никаких
// гарантий, что эта оптимизация
// будет актуальна завтра.
if (someObject !== void 0) {}

Явность. Вот ключевое слово. Между неявным и явным мы должны выбирать явное. Так же как явное решение подскажет программисту, который будет дальше работать с этим кодом, что тут происходит, так же оно может подсказать интерпретатору, как сгенерировать оптимальный код. Наши знания об оптимизациях устаревают, но явное остаётся явным. Движки действительно плохо оптимизировали перебирающие методы несколько лет назад. Однако сейчас код, написанный с помощью таких конструкций, может быть даже более производительным. Для примера взглянем на бенчмарки 2017 года после проведения оптимизаций в V8.

https://twitter.com/goodmodule/status/840227134689935362

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

Хотите другой пример? Не верите на слово?

Бенедикт Маурер, один из разработчиков движка V8, приводит достаточно наглядные примеры того как использование новых синтаксических конструкций приводит к оптимизации выходного машинного кода.

Сравнение с undefined
Вариант слева в 4.5 раз быстрее варианта справа (на момент доклада)

При этом сравните количество ментальных усилий, необходимых для того, чтобы понять, что делает код справа. Да, он простой, но код слева сильно проще!

Кстати, есть пример и с приведённым выше случаем явного сравнения с undefined. Сравните объём машинного кода, необходимого для корректной обработки неявного условия.

spread-оператор

Что же хотят сказать нам ребята из Google этим докладом? Пишите простой явный код, а интерпретатор сделает хорошо за вас. Просто подскажите ему, как сделать лучше.

Существуют ли «убийцы оптимизации», когда новая хайповая конструкция языка превращается в тонну неэффективного кода? Безусловно. У каждого движка есть свои проблемы, и ещё не все оптимизации сделаны. Должны ли мы об этом беспокоится, чтобы уже сегодня наш код работал быстрее?

Об этом вторая часть статьи.

Является ли производительность кода важнейшим критерием качества?

Нужно ли помнить обо всех «убийцах производительности»? Нужно ли предпочитать менее читабельные конструкции хорошим новым методам? Мне кажется, ответ скрывается за двумя вопросами:

  1. Есть ли у вас проблемы с производительностью?
  2. На какие жертвы вы готовы пойти ради достижения пиковой производительности?

Известно высказывание, приписываемое Дональду Кнуту:

Преждевременная оптимизация – корень всех зол.

Интересно, что существует как минимум три версии этой фразы, и одна из них:

We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil. Yet we should not pass up our opportunities in that critical 3.

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