Некоторые из приёмов будут полезны и тем, кто пишет на других языках. Все способы разделены на группы по убыванию специфичности: от наиболее общих до конкретных. Почти все примеры кода взяты из реальных проектов, из реального продакшена.
-
Организационные
Культура разработки performance-first
Бюджет скорости
Performance mantras -
Те, что можно использовать независимо от языка и его реализации
Смена языка или фреймворка
Смена алгоритма
Оптимизация алгоритма
Вынос инвариантов на уровень выше
Boolean short circuit
Досрочный выход из цикла
Предвычисление -
Для языков/фреймворков, в которых нет ленивых вычислений и приёма copy-on-write
Shortcut fusion
Ленивое вычисление
Copy-on-write
Оверинжиниринг -
Зависящие от железа
Разворачивание мелких циклов
Предсказание ветвлений (Branch prediction)
Доступ к памяти: направление итерации
Доступ к памяти: [i][j] vs [j][i] -
Для языков со сборкой мусора
Мутабельность
Zero memory allocation или GC-free -
Специфичные для JavaScript
Антипаттерн: накопление строк в массиве
Антипаттерн: Lodash _.defaults
Idle Until Urgent
Даунгрейд кода: ES6 → ES5 - Примеры из код-ревью
Организационные
Культура разработки performance-first
Это самое важное. Чем раньше вы начнёте контролировать скорость в вашем проекте — тем лучше для проекта. Это даст возможность заранее избежать серьёзных просчётов, которые потом будет сложно исправить.
В то же время заметьте: я не призываю сразу превращать весь код в нечитабельную «портянку». Главное — осознанно следить за ним, знать, где, что и с какой скоростью у вас работает, и осознавать, когда можно и нужно исправлять конкретные вещи и требуется ли вообще их исправлять.
Бюджет скорости
— Быть быстрее конкурентов на ≈ 20%!
— Открытие страницы в 4G сети < 3 с
— Открытие страницы в 3G сети < 5 с
— Длительность запросов за данными < 1 c
— First Contentful Paint < 1 с
— Largest Contentful Paint < 2 с
— Total Blocking Time < 500 мс
— Lighthouse Performance Score > 70
— Cumulative Layout Shift < 0,1
wp-rocket.me/blog/performance-budgets
Очень важно определить бюджет скорости в проекте. Как именно его определять, какие метрики и какие значения выбирать — это тема отдельного длинного разговора. Главное, чтобы бюджет у вас был.
Performance mantras
1. Don't do it
2. Do it, but don't do it again
3. Do it less
4. Do it later
5. Do it when they're not looking
6. Do it concurrently
7. Do it cheaper
brendangregg.com/blog/2018-06-30/benchmarking-checklist.html
Ещё один интересный подход к проблеме. Попробуйте применить семь шагов мантры к вашей проблеме скорости и производительности кода. Если один из них подскажет путь решения, смело его используйте. Такой подход работает: все приёмы, которые я дальше покажу, подпадают под какой-либо из пунктов мантры.
Следующая группа приёмов будет полезна не только в JavaScript.
Те, что можно использовать независимо от языка и его реализации
Смена языка или фреймворка
Самое главное: если вы понимаете, что ваши инструменты не подходят для данной задачи, то как можно раньше ищите другие, более подходящие. Либо вообще смените язык программирования или фреймворк. Если подходящих нет, напишите свои. Так, в конце концов, родились многие известные сейчас фреймворки и библиотеки.
Смена алгоритма
Если текущий язык вам подходит и вы его не меняете, но проблема в конкретном алгоритме, то поищите — возможно, есть алгоритмы, которые делают ту же самую задачу, но с меньшей сложностью. Например, можно попробовать перейти от O(N2) к O(N log N) или к O(N). Проверьте, как алгоритм работает именно с вашими данными. Возможно, данные у вас в продакшене — это наихудший вариант, в котором именно этот алгоритм показывает наихудшую производительность. Тогда можно найти альтернативы, которые будут работать с той же сложностью, но именно на ваших данных показывать лучшую производительность.
Оптимизация алгоритма
Если лучших вариантов нет, посмотрите на реализацию текущего алгоритма. Постарайтесь уменьшить количество итераций, проходов по коллекциям и массивам. То есть N-1 проход — это быстрее, чем N проходов, хотя в O-нотации получается одна и та же сложность.
Если в вашем сервисе используется сложная математика, которая занимает время, попытайтесь её упростить. Однажды мы искали точки на плоскости, ближайшие к заданной. Для формулы вычисления расстояния нам нужен был квадратный корень: Math.sqrt(dx**2 + dy**2). Но для поиска ближайшей точки достаточно было сравнивать квадраты расстояний: dx**2 + dy**2. Это даст тот же самый ответ, но при этом мы избавимся от медленного вычисления корня.
Посмотрите внимательно на свои сложные вычисления — возможно, у вас получится применить такой же подход.
Вынос инвариантов на уровень выше
Бывает, что на каждой итерации вы постоянно проверяете или вычисляете одно и то же выражение, которое в следующей итерации не изменится. Например:
items.forEach(i => doClear ? i.clear() : i.mark());
Вместо того чтобы вычислять его N раз, было бы неплохо вынести его из цикла и проверять или вычислять только один раз:
if (doClear) items.forEach(clear)
else items.forEach(mark);
Как вариант, в этом коде мы можем по условию подставлять нужную функцию-итератор в перебор массива через forEach:
const action = doClear ? clear : mark;
items.forEach(action);
Такой приём касается не только циклов и операций над массивами. Он также применим к методам и функциям. Вот изоморфный код, который вычисляет, видима ли domNode в указанной точке экрана:
const visibleAtPoint = function(x, y, domNode) {
if (canUseDom && document.elementsFromPoint) {
// ...
}
};
Чтобы работать на сервере, код проверяет, можно ли использовать DOM. А когда код выполняется в браузере, он проверяет, обладает ли браузер требуемым API. Такие проверки происходят при каждом вызове функции. Это ненужные затраты времени и на сервере при server-side rendering, и на клиенте, потому что браузер или обладает API, или нет, и это достаточно проверить один раз.
Способ это исправить — завести две реализации, по одной для каждого из вариантов, проверить один раз, обладает ли наша среда выполнения нужными свойствами, и в зависимости от этого подставить нужную реализацию. Внутри реализации уже не будет никаких if, никаких проверок условий. Она будет работать максимально быстро.
const visibleAtPoint =
(canUseDom && document.elementsFromPoint) ? fn1 : fn2;
Boolean short circuit
Сейчас, к сожалению, часто забывают про Boolean short circuit. Даже JavaScript умеет не вычислять до конца логические выражения, составленные из операторов и/или, если он может заранее предсказать результат, как в этом примере.
const isVisibleBottomLeft =
visibleAtPoint(left, bottom, element);
const isVisibleBottomRight =
visibleAtPoint(right, bottom, element);
return isVisibleBottomLeft && isVisibleBottomRight;
Код проверяет видимость нижней левой и нижней правой точки прямоугольника — диалога, модального окошка. Если нижний левый угол уже не виден, то нам не надо вычислять видимость нижнего правого угла. Но в такой записи мы всё равно сначала всё вычислим и потом подставим в логическое выражение. Это без нужды замедляет наш код, особенно если проверки идут очень часто.
Чтобы воспользоваться преимуществами Boolean short circuit, надо подставлять вычисления и вызовы функций в само выражение. Тогда не будет вычисляться то, что не нужно.
return visibleAtPoint(left, bottom, element) &&
visibleAtPoint(right, bottom, element);
Досрочный выход из цикла
Иногда мы уже знаем результат нашего выражения, и нам не обязательно совершать поиск по всему массиву, как в этом примере:
let negativeValueFound = false;
for (let i = 0; i < values.length; i++) {
if (value[i] < 0) negativeValueFound = true;
}
Мы ищем, есть ли в массиве отрицательные значения. Но автор кода забыл, что, возможно, отрицательным будет самый первый элемент и нет смысла перебирать все оставшиеся, когда мы уже знаем ответ. Поэтому смотрите на свой код. После того, как вы узнаете ответ, нет необходимости продолжать итерацию по массивам и коллекциям. Надо досрочно возвращать результат:
let negativeValueFound = false;
for (let i = 0; i < values.length; i++) {
if (value[i] < 0) {negativeValueFound = true; break;}
}
Такими методами, досрочно завершающими перебор, у массива являются find, findIndex, every и some. Есть проблема с методом reduce: он продолжает перебор массива до конца. В этом случае можно использовать библиотечные методы, например, Lodash предоставляет метод transform — аналог reduce, но с возможностью досрочного выхода.
Предвычисление
Иногда некоторые константы можно посчитать заранее. Например, мы обрабатываем все точки изображения и вычисляем кубический корень из компонент RGB каждой точки:
const result = Math.pow(r, 1/3);
Очень часто при работе с изображениями нужны сложные и затратные математические вычисления. Скорость вычислений не очень большая: на моей машине получается примерно 7 млн операций в секунду. Другими словами, за секунду мы успеем обработать примерно два мегапикселя картинки. Это маловато для современных машин.
Мы можем заметить, что константа ⅓ вычисляется каждый раз, и запомнить её. При этом скорость работы увеличится до 10 млн операций в секунду.
const N = 1/3;
const result = Math.pow(r, N);
Но этого всё ещё недостаточно. Обратите внимание, что чаще всего компоненты R, G и B представляют собой байты. То есть каждый из них принимает всего лишь 256 значений. Соответственно, наш кубический корень, как и результат любой формулы над байтом, тоже может принимать только 256 значений.
Мы можем записать заранее все значения формулы, какой бы сложной она ни была, а в рантайме всего лишь выбирать нужное значение из массива:
const result = CUBE_ROOTS[r];
Мы получаем примерно десятикратное ускорение по сравнению с первоначальным кодом — точные результаты могут немного отличаться. Чем сложнее формула, тем большее ускорение мы можем получить.
Такой приём называется lookup table (LUT): мы записываем заранее вычисленные значения в табличку и ценой дополнительной памяти получаем дополнительную скорость.
Для языков/фреймворков, в которых нет ленивых вычислений и приёма copy-on-write
Shortcut fusion
Это интересная концепция, которая полностью отсутствует в JavaScript. Предположим, у вас есть необходимость в целой цепочке выражений над массивом: array.map().filter().reduce(). JavaScript будет делать всё это последовательно. Сначала выполнит map, построит промежуточный массив. Потом выполнит filter, построит второй промежуточный массив и в конце выполнит reduce над всеми элементами промежуточного массива. Получается три прохода, которые мы могли бы объединить в один, сделав shortcut fusion: написав один сложный array.reduce() с кодом из наших map, filter и reduce.
Бонусы shortcut fusion: промежуточные структуры данных не создаются и не потребляют память, мы не копируем в них содержимое предыдущего промежуточного массива, а число итераций уменьшается до одной. В мощных языках это делает под капотом сам компилятор. Мы в JavaScript вынуждены делать это вручную.
Ленивое вычисление
Оно тоже отсутствует в JavaScript. Иногда из всего массива нам требуется только пять первых элементов: arr.map().slice(0, 5). Или первый элемент, который удовлетворяет какому-нибудь условию: arr.map().filter(Boolean)[0]. Подобные вещи в JS выполняются неэффективно: сначала мы делаем все операции над массивом целиком, а потом оставляем только нужные элементы.
В следующем примере надо вычислить первые пять квадратных корней из нечётных элементов массива. Если мы запишем такую конструкцию в лоб, используя filter и map, то сложность реализации будет O(N):
array
.filter(n => n % 2)
.map(n => Math.sqrt(n))
.slice(0, 5);
Нам на помощь может прийти библиотека Lodash. В ней это же вычисление записывается очень похоже, но имеет сложность, близкую к константной:
_(array)
.filter(n => n % 2)
.map(n => Math.sqrt(n))
.take(5).value();
Lodash под капотом использует и shortcut fusion, и ленивое вычисление, находя только первые пять элементов. Неважно, какова длина массива: как только мы найдём первые пять элементов, Lodash прекратит вычисления.
Аналогичные возможности, помимо Lodash, реализованы и в других библиотеках, например, Immutable и Ramda. Используйте их, когда у вас появляются такие цепочки вычислений.
Copy-on-write
В тех языках, где используется иммутабельность и копирование структур, есть приём: ленивое копирование содержимого этих структур в новый экземпляр, которое происходит, только когда мы действительно собираемся менять значение внутри.
const state = {
todos: [{todo: "Learn typescript", done: true}],
otherData: {}
};
Для нас это очень важно, потому что мы используем React, Redux и иммутабельный state. При этом создавать вручную следующий state из предыдущего очень неудобно:
const nextState = {...state,
todos: [...state.todos, {todo: "Try immer"}]};
Нам на помощь могут прийти библиотеки, которые за нас реализовали паттерн copy-on-write, например библиотека Immer.
const nextState = produce(state, draft => {
draft.todos.push({todo: "Try immer"});
});
Мы как будто получаем следующий экземпляр state и смело делаем его мутирование, то есть добавляем в него элементы, меняем значения полей, а Immer под капотом производит копирование всего остального и добавление новых данных.
Помимо Immer есть несколько библиотек, в которых реализованы похожие вещи. В библиотеке Immutable есть методы updateIn, которые явно работают с иммутабельными структурами. В библиотеке Ramda есть концепция, которая называется «линзы». Мы создаём линзу и указываем в ней путь внутри объекта, в котором надо сделать мутацию значения. Читайте документацию, используйте эти библиотеки, когда нужно работать с иммутабельным state и другими иммутабельными структурами.
Оверинжиниринг
Порой легко увлечься и усложнить код там, где на самом деле можно сделать намного проще. Например, если вам нужно сделать обратный порядок элементов в массиве, для этого не нужны дополнительные библиотеки или сложный код, как здесь:
array.map().reverse()
Есть классический цикл for, в котором мы можем задать обратное направление:
for (let i = len - 1; i >= 0; i--)
Предположим, вам необходимо сделать вычисление над частью массива:
array.slice(1).forEach()
Тогда, опять же, можно использовать for, задав в нём нужный диапазон индексов:
for (let i = 1; i < len; i++)
Бывает, что мы усложняем код, делая слишком сложную цепочку:
_.chain(data)
.map()
.compact()
.value()[0]
Мы можем упростить и ускорить её, заменив на один вызов _.find() и проделав операции из map() только с одним найденным элементом
Зависящие от железа
До сих пор неявно предполагалось, что всё, что мы пишем, выполняется на идеальных компьютерах со сверхбыстрыми процессорами и мгновенным доступом к памяти. В реальности это не так, что в некоторых горячих местах становится особенно заметно.
Разворачивание мелких циклов
Как вы, наверное, знаете, мелкие циклы выполняются неэффективно, потому что затраты на организацию цикла перевешивают затраты на выполнение самого кода в цикле. В компилируемых языках компилятор разворачивает подобные мелкие циклы за вас и подставляет скомпилированный код, но в JavaScript приходится делать это вручную.
[1, 2, 3].map(i => fn(i))
В горячем коде, если вы заметите мелкие циклы на несколько элементов с использованием for, map, forEach, лучше развернуть их вручную:
[fn(1), fn(2), fn(3)]
Предсказание ветвлений (Branch prediction)
Если процессор может предугадать в вашей проверке if или switch, куда дальше передастся выполнение, он заранее начнёт разбирать этот код и выполнит его быстрее.
Вот пример бенчмарка (это синтетический бенчмарк, в реальном коде я аналогичных примеров не встречал). Есть массив из 100 тысяч элементов, которые мы перебираем в цикле. Внутри цикла стоит if, и в зависимости от проверки мы обрабатываем ровно половину элементов, а половину — нет.
Но в первом случае массив отсортирован, и мы сначала обрабатываем 50% элементов, а потом 50% оставшихся не обрабатываем. А во втором случае элементы, которые нужно обработать, случайно перемешаны по всему массиву. Нужно обработать ровно столько же элементов, но Branch prediction при этом не работает.
Обработка такого неупорядоченного массива занимает в разы больше времени даже на современных машинах: 550 мс против 130 мс. То есть даже в JavaScript Branch prediction может оказать заметное влияние на вычисления.
Если вы управляете порядком данных — например, тем, с какой сортировкой они приходят с бэкенда, — обратите на это внимание. Этот приём может помочь вам ускорить код.
Доступ к памяти: направление итерации
Как вы знаете, доступ к памяти происходит не мгновенно — современные компьютеры используют кэширование и упреждающее чтение данных для ускорения процесса. Есть старый паттерн, который родился в шестом Internet Explorer при операциях над циклами и строками: итерация в обратном направлении тогда была самой быстрой. С тех пор паттерн очень часто повторяется в современном коде «для большей скорости».
let i = str.length; while (i--) str.charCodeAt(i);
Но, к сожалению, это уже давно не так. В современных браузерах направление вперёд обычно работает быстрее (в данном примере — 1,6 против 1,4 млн операций в секунду):
for (let i = 0; i < str.length; i++) str.charCodeAt(i);
Даже на относительно коротких строках из нескольких сотен символов мы можем легко заметить разницу в скорости во всех современных браузерах и в Node.js. Пример из библиотеки хэширования строк.
Так что не используйте этот паттерн, пишите простой цикл for и итерацию в прямом направлении. Таким образом железо сможет наиболее оптимально дать вам следующие данные, которые вы собираетесь читать или менять.
Доступ к памяти: [i][j] vs [j][i]
Предположим, у вас есть двумерные структуры. Например, вы прочитали записи таблицы или двумерный массив в память. Тогда имеет смысл расположить последовательно в памяти строки или колонки, по которым вы будете итерироваться.
Если вы обрабатываете массив построчно, элементы одной строки должны лежать в памяти рядом. Если вы сканируете таблицу по колонке — например, ищете запись по индексу в таблице базы данных, — именно эта колонка должна лежать в соседних ячейках памяти. Такой приём может дать заметный прирост скорости (1 2).
Для языков со сборкой мусора
Эта группа оптимизаций подходит для языков, в которых есть garbage collection и автоматическое управление памятью: JavaScript, C#, Java и некоторых других.
Мутабельность
Первая проблема — плохая поддержка иммутабельности. Иммутабельность объектов означает генерацию новых объектов, иногда с довольно большой скоростью. А старые объекты должны собираться через garbage collector. В горячем коде это может очень сильно влиять на скорость работы. Именно затраты на сборку мусора могут превышать затраты на работу вашего кода. И если вы видите, что в горячем коде есть сильное потребление памяти, постарайтесь использовать мутабельность: убрать spread, убрать клонирование объектов и мутировать существующие объекты.
Иногда это можно сделать довольно безболезненно.
const obj = createNewObj();
return {...obj, prop: value};
Например, в таком горячем участке кода мы создаём свежий объект с нуля. Это гарантированно уникальный объект, никто на него не ссылается. И тут же, в следующей строке, мы его клонируем в новый объект. Здесь совершенно зря происходит и клонирование объекта, и создание мусора. Этот кусочек кода можно переписать вот так, будет намного быстрее:
const obj = createNewObj();
obj.prop = value;
return obj;
Но это, повторюсь, только в горячем коде. В остальных местах такое решение усложнит код, сделает его менее читаемым и менее сопровождаемым.
Zero memory allocation или GC-free
Так называются алгоритмы с низким или нулевым потреблением памяти. Общий приём в подобных алгоритмах — использование пула объектов. Мы один раз создаём N объектов заданного типа, и те, кто ими пользуются, мутируют их, как им надо, а потом возвращают обратно в пул. Таким образом, нет потребления новой памяти.
Предположим, есть возвращаемый объект, который нужен один раз «на выброс» — то есть надо сделать однократно какую-то операцию, и больше нас объект не интересует, мы нигде не сохраняем ссылки на него. Тогда можно использовать синглтон. Этот паттерн называется flyweight object.
Вот пример из фреймворка ExtJS:
Ext.fly(id)
Use this to make one-time references to DOM elements which are not going to be accessed again either by application code, or by Ext's classes.
Это довольно частый паттерн работы с DOM: мы получаем по идентификатору DOM-элемент, на нём проверяем или меняем CSS-класс, стили, атрибуты и выбрасываем его, так как он нам больше не нужен. В этом случае подходит именно flyweight object.
В других языках самое распространённое применение этого алгоритма — в библиотеках логирования, которые могут вызываться очень часто, поэтому нагрузка на память становится важной. Вот ссылки на клиенты логирования в языках Go и Java:
- Клиент go-statsd — пулы объектов и GC-free-код: github.com/smira/go-statsd/blob/master/README.md#zero-memory-allocation
- Фреймворк логирования log4j — GC-free-код: logging.apache.org/log4j/2.x/manual/garbagefree.html
В документации хорошо описано, что именно и как именно делалось. Можно найти и проанализировать пулл-реквест, в котором снижалось потребление памяти.
Специфичные для JavaScript
Эта группа оптимизаций наиболее близка именно к JS и мало применима в других языках.
Антипаттерн: накопление строк в массиве
Ещё один антипаттерн со времён шестого Internet Explorer — если нужно накопить длинную строку из кусочков, некоторые разработчики до сих пор сначала собирают эти строки в массив, чтобы потом вызвать join:
[string1, string2, … stringN].join('')
К сожалению, это работало быстро только в шестом Internet Explorer. С тех пор стало гораздо быстрее суммировать строки «в лоб»:
string1 + string2 + … + stringN
Потому что в браузерах для такого представления строки есть специальный класс ConsString, «конкатенированная строка». Он позволяет осуществить сложение строк за константное время, то есть сохраняет внутри только две ссылки на две суммируемых строки и не занимается физическим копированием байтиков из одного места в другое. Так что суммируйте строки как есть, не используйте для этого массив и join.
Антипаттерн: Lodash _.defaults
Когда у нас есть объект, в котором мы хотим завести дефолтное значение, для этого мы часто используем функцию _.defaults из Lodash или её аналоги. В этом случае в сами дефолтные значения легко записать результат сложных вычислений, которые занимают длительное время.
_.defaults(button, {
size: getDefaultButtonSize(window),
text: getDefaultButtonText()
});
В этом примере кода, когда приходят пропсы для кнопки, мы хотим, чтобы у них были дефолтные размеры и текст. Вычисления дефолтных размеров и текста мы проделываем для дефолтных полей, даже если в пришедших к нам свойствах кнопки уже есть поля size и text. То есть мы сначала вычислим объект дефолтных значений и потом решим, будем ли его использовать.
Быстрый некрасивый фикс: сначала проверять, нужны ли нам дефолты в данном поле, и только тогда выполнять тяжёлые вычисления:
if (button.size === undefined)
button.size = getDefaultButtonSize(window);
if (button.text === undefined)
button.text = getDefaultButtonText();
Но, конечно, такой код получается некрасивым. Немного красивее будет написать с использованием геттеров:
_.defaults(button,{
get size() {return getDefaultButtonSize(window)},
get text() {return getDefaultButtonText()}
});
Вариант ещё лучше: если вы понимаете, что у вас в горячем коде часто генерируются пропсы, в которых используется дефолтное значение, сделайте правильную генерацию этих пропсов, чтобы внутри кода, который их генерирует, все они сразу получали дефолтные значения. Постарайтесь сделать этот код красивым и быстрым — это вполне достижимый результат.
Idle Until Urgent
Часто мы в конструкторе объекта инициализируем все поля, которые нам, возможно, потребуются только после некоторых действий пользователя или не потребуются вообще — как в этом примере, взятом из статьи Филипа Уолтона:
constructor() {
this.formatter = new Intl.DateTimeFormat(…);
}
handleUserClick() {
const formatter = this.formatter;
this.clickTime = formatter.format(new Date());
}
Мы создаём поле formatter для форматирования даты и времени, и это создание длится очень долго. Но formatter, возможно, будет использован только через большой промежуток времени, когда пользователь на что-то нажмёт.
Мы можем погрузить медленное создание объекта formatter в обёртку, которая выполнит код создания объекта, когда браузер будет свободен и пользователь не будет ничего делать или когда нам явно потребуется этот formatter:
constructor() {
this.formatter = new IdleValue(
() => new Intl.DateTimeFormat(…));
}
handleUserClick() {
const formatter = this.formatter.getValue();
this.clickTime = formatter.format(new Date());
}
IdleValue — класс, реализующий ленивую инициализацию. Описан в вышеупомянутой статье и в библиотеке idlize.
Так мы сэкономим время на критическом этапе загрузки страницы и не будем замедлять создание объектов.
Даунгрейд кода: ES6 → ES5
Не секрет, что до сих пор многие фичи ES6 и более новые работают медленнее, чем их аналоги из ES5. В горячем коде попробуйте заменить их на ES5-код и, возможно, получите ускорение.
- Итераторы, for-of, map/reduce/forEach заменяйте на for
- Object.keys, Object.entries заменяйте на for-in
- Старайтесь не использовать rest и spread
Пример — хуки в React рекомендуют деструктурировать вот таким образом:
const [x, setX] = useState(0);
Если у нас нет поддержки деструктурирования, то мы транспилируем этот код в нативный ES5-вариант, в котором мы сначала получаем массив, а потом читаем из него элементы по двум индексам:
const state = useState(0),
x = state[0],
setX = state[1];
Как ни странно, когда появилось нативное деструктурирование, этот ES5-вариант работал в разы быстрее нативного, потому что под капотом деструктурирование двух элементов массива реализовано через создание итератора, два вызова next, две проверки на достижение конца итерации, и всё это ещё завёрнуто в try-catch. Такая нативная реализация деструктурирования в принципе не может работать быстрее, чем просто доступ к двум элементам массива.
Как мы видим по бенчмарку, со временем деструктурирование в Chrome было немного оптимизировано в ущерб транспилированному варианту. А в других популярных браузерах деструктурирование до сих пор работает медленно:
Нативный код (M ops/s) |
Транспилированный в ES5 (M ops/s) | |
Chromium 71 (2018 год) | 20 | 60 |
Chrome 89 | 24 | 25 |
Firefox 87 | 7 | 15 |
Safari 14 | 13 | 23 |
Примеры из код-ревью
На закуску — примеры реального кода, увиденные во время код-ревью.
- Как вы думаете, что делает такой код? Что хотел сделать его автор?
Boolean(_.compact(array).length)
ОтветКод проверяет, есть ли хоть один не пустой элемент в массиве. Вы наверняка догадались, что можно использовать приём, о котором я уже рассказал: выходить из итерации, как только найден ответ, а не перебирать все элементы до конца:array.some(x => Boolean(x))
- А такой?
array.sort((a, b) => b - a)[0]
ОтветАвтор хотел наиболее компактно, сэкономив лишние буквы, найти максимальный элемент в массиве. Но как вы понимаете, стоимость такого поиска намного больше, чем хотелось бы. Зная это, мы можем использовать специальный метод из библиотеки или написать свой, утилитный:_.max(array)
- Представьте такой угар по иммутабельности:
array.reduce((acc, value) => [...acc, someFn(value)], [])
Даже тот массив, который создан внутри reduce, мы всё равно каждый раз клонируем и тем самым занимаем лишние время и память. Используя мутабельность, можно было бы сильно ускорить выполнение:array.reduce((acc, value) => { acc.push(someFn(value)); return acc; }, [])
- Последний пример кода:
arr.filter(predicate).length
Здесь остановлюсь немного подробнее. Мы хотим посчитать количество элементов, которые удовлетворяют условию (для которых predicate вернёт true). Если бы у нас был умный компилятор, или использовался язык с ленивыми вычислениями, то он бы догадался, что нам от промежуточного массива после filter нужна только длина. В коде вычисления этого промежуточного массива он оставил бы только увеличение длины массива при выполнении условия predicate. А само создание массива и копирование его элементов — выбросил бы. Фактически он бы за нас написал конструкцию, в которой лишь инкрементируется длина массива.
Но в JavaScript, к сожалению, мы должны сами видеть, что такой код выполняется неоптимально и оптимизировать его вручную, выполняя работу за компилятор:arr.reduce((count, x) => predicate(x) ? count + 1 : count, 0)
Вместо заключения
Вот страница на GitHub, где я собрал все упомянутые в тексте ссылки. Надеюсь, вы попробуете применить эти приёмы на практике и поделитесь опытом в комментариях. Также пишите, если уже пробовали что-то из списка или если у вас есть свои идеи, как ускорить работу кода на JavaScript, — обсудим. Спасибо за внимание. Всем быстрого кода!
Комментарии (51)
fransua
03.08.2021 13:18array.reduce((acc, value) => { acc.push(someFn(value)); return acc; }, [])
А это разве не
array.map(someFn)
?victor-homyakov Автор
03.08.2021 13:30+1Правильно, в таком виде это вообще можно заменить на
map
. В оригинале вreduce
было ещё немного логики. Я не придумал, как сделать, чтобы она не отвлекала от сути вопроса и выбросил её, прошу прощения.
Sabubu
03.08.2021 13:36+4Мне не нравится функция reduce. Она делает код нечитаемым. Надо вдумываться, мысленно представлять, как перемещаются данные, чтобы понять, что хотел сказать автор кода. Обычный цикл в разы проще прочесть. Когда видишь reduce, то складывается ощущение, что автор хотел не писать понятный код, а посоревноваться в знании экзотических конструкций.
Вот пример нечитаемого кода из статьи, в который надо долго вглядываться, чтобы понять, как он выполняется:
arr.reduce((count, x) => predicate(x) ? count + 1 : count, 0)
И вот, как его надо переписать:
countWhere(arr, predicate);
Либо:
count = 0; for (x of arr) { if (predicate(x)) { count ++; } }
Этот вариант занимает больше строк, но глаз сразу цепляется за for/if и видит суть кода. Тот, кто написал исходный код, хотел показать, какой он умный и как он умеет пользоваться функциональным подходом, но фактически он показал, что он глупый, так как не додумался вынести код в функцию с понятным названием countWhere.
Давайте не забывать, что функции вроде map/reduce пришли из функциональных языков, где нет нормальных циклов и приходится искать обходные пути. Это по сути костыли. В императивных языках циклы есть и надо ими пользоваться.
Цикл еще и быстрее, чем функции вроде map/forEach/reduce, так как нет накладных расходов на вызов функции.
Что касается статьи, то приведенные оптимизации выглядят очень сомнительно. Если у вас используется React и на любое движение мыши вы обходите огромное дерево из сотен компонентов, создаете тысячи узлов виртуального DOM, сравниваете его с реальным, то у вас основное время будет тратиться на это и надо думать, как это можно оптимизировать, а не пытаться ускорить цикл по 3 элементам. У вас код тормозит не из-за предсказания ветвлений, а из-за того что вы налепили тысячи компонентов и используете медленные технологии вроде React.
victor-homyakov Автор
03.08.2021 13:53React - это вообще отдельная тема, с ним действительно можно легко написать тормознутый UI. У меня о производительности кода на React есть отдельный рассказ https://habr.com/ru/company/yandex/blog/536682/.
alexdesyatnik
03.08.2021 14:11+4А может быть, дело банально в привычке? Вы привыкли к циклам и условиям, но не привыкли к функциональщине, поэтому легко "выцепляете" шаблонный цикл-условие, но не выцепляете не менее шаблонный reduce. Да, на reduce можно нагородить нечитаемый код - как с помощью любого другого инструмента - но ваш пример явно не из этой категории, долго вглядываться там нужно только если reduce никогда не использовали.
И нет, map/reduce это не костыли, это инструменты, которые позволяют выразить мысль гораздо более явным образом, нежели циклы. Абстракция более высокого уровня.
Sabubu
03.08.2021 20:51-4Нет, не согласен. Во-первых, countWhere/countIf короче и читабельнее чем reduce, так как из названия понятно, что она делает. Во-вторых, если не использовать готовые функции, то цикл через for или forEach() будет читабельнее.
alexdesyatnik
03.08.2021 22:23+4Готовые функции то да, но смысл огород городить ради одного раза. А вот насчёт читабельности - субъективно. Я не то чтобы очень много функциональщиной занимался, но определённый опыт есть, так что само слово reduce сходу говорит, что сейчас будет какая-то поэлементная обработка с агрегированием результата, а уж дальше выцепить шаблонное условие ничуть не сложнее обычного цикла. Если же говорить о какой-то более сложной обработке, то цепочка типа .map(...).filter(...).reduce(...) может оказаться в разы читабельней реализации через циклы и условия, т.к. каждый шаг чётко говорит, что именно он делает.
bakhirev
04.08.2021 00:00+2<сарказм> for(var i = arr.length - 1, total = 0; i > -1; i--, total += predicate(arr[i])); console.log(total); </сарказм>
aNNiMON
03.08.2021 14:12+4Для Boolean short circuit могу предложить ещё один вариант, если не хочется длинные вызовы в один объединять:
let result = true;
result = result && visibleAtPoint(left, bottom, element);
result = result && visibleAtPoint(right, bottom, element);
return result;
granvi
03.08.2021 16:28Ну послушайте. Этим "приемам" сто лет в обед. Это не эксклюзив от разработчика яндекса. Мало того, очень многих "приемов" связанных, например, битовыми манипуляциями нет и в помине. А ведь это очень эффективеые методы. Зачем выдавать накопленный общий опыт за свой? Это просто некрасиво.
ElleSolomina
03.08.2021 20:53+5Статья не об этом. Статья говорит "смотрите, мы работаем в Яндексе, у нас большая нагрузка, мы понимаем что железо стоит денег, электричество стоит денег и т. д.", Яндекс тут только из-за этого. Статья полезная, о таких вещах надо писать постоянно! Разжирение программного обеспечения это реальная проблема.
Нытьё: у меня наболело я только на днях разгребла кусок ужасающего кода проекта работы с WebRTC где буквально всё было на shared_ptr хотя крошечные менеджеры и ещё более крошеные механизмы достаточно было десяток раз создать на жирном стеке десктопной ОС, а главное никакой многопоточности или множественного владения объектами в коде просто нет и не должно быть. Для обмена сообщениями, а в проекте априори обмен всегда только лишь между парой потоков, скопипащены монструозные слоты-сигналы где на каждый слот, который ещё и подключается в runtime может подписываться толпа потоков и этот список там обрабатывается и внутри ещё всё обёрнуто прослойкой из copy on write и каждый мехенизм обёрнут своей синхронизацией -_- Корректный обратный вызов пока не сделан потому что надо архитектуру от адка оверинжиниринга во всём вычистить окончательно поскольку сейчас в 3 основных классах такая сложная связь через обсерверы, что получается хрень где не понятно кого кто должен создать чтобы корректно всё инициализировалось и кто кем владеет. Меня эта ситуация дико напрягает, потому что это высокопроизводительный проект, который должен потреблять как можно меньше ресурсов, где через WebRTC должна передаваться, с минимальными задержками куча информации, ещё и кодеки должны работать, да и в остальном машина будет нагружена полезным софтом помимо стриминга, а данные должны ходить в обе стороны с как можно минимальными задержками.
ElleSolomina
03.08.2021 20:56Мда, теперь на Хабре даже коммент отредактировать вообще нельзя :) Круто, повод ещё реже сюда ходить.
Videoman
03.08.2021 23:30+2О, как мне это знакомо. Приходилось заниматься тем же самым. В WebRTC и Chrome в целом прямо видно нагромождение вековых отложений: плео-хром, мезо-хром, нео-хром. Все примитивы дублируют свой функционал на всех уровнях. Я не удивляюсь что webrtclib практически врос в chrome и использует кучу общих библиотек, без которых он не собирается и от которых его не отделить. Если смотреть на разные версии то видно как параллельно это все кусками рефакторится и выпиливается.
В самом начале меня поразило насколько это просто высокоуровневый кусок Хрома с классами которые просто один к одному лежат под Web API.
Потом еще пришлось самому реализовывать H.264 кодек, т.к. было необходимо аппаратное ускорение, а родной кодек является сторонней библиотекой, которая тащит за собой еще больший кусок Хрома.ElleSolomina
08.08.2021 14:07+1О да, а мне из-за патентных маразмов придётся ещё либо ffmpeg либо ещё что-то тащить в проект ради HVEC (h265) ^^'
Videoman
09.08.2021 01:29+1Под реализацией своего кодека я как раз и имел в виду ffmpeg. Но всё равно, пришлось реализовывать всю обвязку для chroma, что бы он мог использовать аппаратное ускорение, менять битрейт, частоту кадров, передавать медиа-семплы без копирования и т.д.
granvi
05.08.2021 01:35-2Если статья "не об этом" зачем такой заголовок? "Однажды мне в голову пришла идея обобщить свой опыт и систематизировать приёмы ускорения работы кода" это тоже "не об этом"?
Все эти методы должны входить в обязательный курс разработчика.
ElleSolomina
08.08.2021 14:09Да, должны входить в обязательный курс :) Но мы живём в реальном мире, кто-то может давно начинал, кто-то просто не придавал этому значению, поэтому писать о таком надо. Чтобы веб работал хорошо, быстро и у пользователя был положительный опыт.
weiser
04.08.2021 09:40-3Большое спасибо за статью, но позвольте небольшой оффтопчик? Ускорение JS кода это, конечно, здорово, но может ли Яндекс исправить, наконец, утечки памяти на Дзене? У меня некоторые статьи там убивают вкладку в хроме ещё на этапе загрузки страницы.
Forum3
05.08.2021 13:14У меня еще веб-страница Яндекс Радио стала дико течь. Говорят из-за рекламы, фиг знает.
Ноутбук начинает шуметь так, как будто обрабатывает что-то.
Перешел на Spotify из-за этого
ElleSolomina
08.08.2021 14:15Мяф, я не работаю в Яндекс, но, буквально за пару кликов нашла вот это:
yandex.ru/support/zen/troubleshooting/feedback.html
То что Яндекс сфейлился и у них нет технического фидбека наружу ¯\_(ツ)_/¯ но жаловаться надо точно куда-то туда. Тут точно не услышат.
P. S. и для Forum3 тоже ^^
errarrehumanumest
05.08.2021 12:20в статье много раз употребляется термин "горячий код". А что это значит?
victor-homyakov Автор
05.08.2021 12:33+2Чаще всего в приложении разные части кода выполняются разное количество раз (например, часть кода выполняется только один раз при старте, часть - один раз на запрос или действие пользователя, часть выполняется O(N) раз, часть - O(N^2) раз и так далее). Код, который выполняется чаще всего, многие называют "горячим".
Исторически это может быть связано с компилятором HotSpot для виртуальной машины Java - в нём есть понятие "hot spots", горячих точек в коде, которые нужно компилировать в первую очередь. В JProfiler, инструменте анализа производительности для Java, один из режимов просмотра так и назвали "Hot Spots".
ElleSolomina
08.08.2021 14:19Дополню, что, например, в Firefox в консоли по F12 есть профайлер, в других браузерах это тоже есть, там можно посмотреть топовые места по нагрузке, но дальше надо понять кто эту нагрузку спровоцировал, например большое количество дёрганий DOM или что-то подобное ;)
Fodin
08.08.2021 06:09Поиск максимального элемента в массиве:
Math.max(...array)
Только для не слишком большого массива, ибо берегите стек.
victor-homyakov Автор
08.08.2021 14:23Посчитал на устройствах, которые были под рукой, максимальный размер массива чисел, который можно таким образом передавать https://jsfiddle.net/obqpag3c/
Chrome 92 Windows - 125670
Chrome 92 Android 9 - 220956
Safari 13.1.3 macOS - 65536
Так что действительно, допустимый размер массива не слишком большой.
Fodin
08.08.2021 14:53Firefox 90 Windows - 500000
Vivaldi 4 Android 10 - 110437
Интересно было бы по скорости сравнить с _.max(array). С одной стороны спрэд увеличивает время вызова, с другой - встроенная функция, которая должна работать "вжжжух". Может, как-нибудь сподоблюсь сравнить.
Чаще всего размер массива известен и невелик, поэтому способ имеет право пожить.victor-homyakov Автор
13.08.2021 16:11На вот таком простом бенчмарке https://jsbench.me/25ksad2y1a/1
в Chrome 92 на macOS
Math.max(...array)
быстрее примерно в 1.5-2 раза на всех допустимых для него размерах массивав Chrome 92 на Android 9 - аналогично
в Safari 13.1 на macOS наоборот,
Math.max(...array)
медленнее примерно в 1.5-2 раза
noodles
09.08.2021 00:05Такими методами, досрочно завершающими перебор, у массива являются find, findIndex, every и some. Есть проблема с методом reduce: он продолжает перебор массива до конца.
Можно выбросить исключение.
victor-homyakov Автор
13.08.2021 14:58Если выбросить исключение -
reduce
вернётundefined
. То есть, чтобы всё-таки получить результат вычислений, надо будет вместо аккумулятора использовать внешнюю переменную. Дальше надо будет завернутьreduce
вtry-catch
, чтобы отловить наше исключение. И ещё надо будет не съесть вtry-catch
все остальные исключения. В итоге у меня получилось примерно так:const BREAK_ERROR = 'BREAK'; // наше исключение для выхода из reduce let result = ...; // аккумулятор try { array.reduce(function(_, n) { result = ...; // накапливаем значения в аккумуляторе if (/* условие прерывания reduce */) { throw new Error(BREAK_ERROR); } }, undefined); // стандартный аккумулятор не используем } catch (e) { if (e.message === BREAK_ERROR) { // прерывание reduce - игнорируем } else { // все остальные ошибки бросаем дальше result = undefined; throw e; } }
Конечно, это тоже можно завернуть в функцию а-ля
_.transform
и убрать с глаз долой. Но кроме некрасивого кода получаем ещё и разницу в скорости работы - на небольших массивах такой код заметно медленнее, чем_.transform
https://jsbench.me/lbksaaah54/1При выходе из reduce/transform на середине массива у меня получились такие результаты:
массив из 100 элементов - transform на порядок быстрее
1000 элементов - transform примерно в два раза быстрее
10000 элементов - transform примерно на 20% быстрее
50000 элементов - скорости примерно одинаковы
100000 элементов - reduce примерно на 20% быстрее
500000 элементов - reduce примерно на 25% быстрее
дальше разница в скорости почти не меняется, остаётся порядка 25%
Chamie
09.08.2021 17:24Предположим, вам необходимо сделать вычисление над частью массива:
Вы хотите сказать, что вот это:
Тогда, опять же, не нужны сложные цепочки вычислений — тоже можно использовать for, задав в нём нужный диапазон индексов:array.slice(1).forEach()
for(let i = 1; i < len; i++)
Это «сложные цепочки вычислений», а вот это:array.slice(1).forEach(element => { // some actions })
↑ просто, коротко, не делает 5 вспомогательных операций и не вводит 2 служебных переменные?const len = array.length; for(let i = 1; i < len; i++) { const element = array[i]; // some actions }
faiwer
09.08.2021 23:18Несмотря на то, что вариант с
for
читается хуже, он на самом деле обязан быть быстрее. Две служебные переменные вводит иslice
иforEach
, просто под капотом. Ноslice
создаёт новый лишний массив. Плюс настоящийforEach
, внутри проверяет массив на дырки, использует.apply
, и делает дополнительныеtype
-проверки.P.S. это не отменяет того что в 99% случаев вариант с
.slice().forEach
подходит бизнесу лучше, т.к. такой код легче писать\читать\поддерживать.Chamie
11.08.2021 11:44Я не спорю со скоростью (слов «быстрее»/«медленнее» я не случайно не использовал), мне просто непонятно, про какие «сложные цепочки вычислений» идёт речь. Причём, чтобы этих вычислений не было во втором варианте.
victor-homyakov Автор
13.08.2021 15:12+1Я имел в виду сами цепочки вызовов
array.slice(1).forEach()
,array.slice(1).map()
и т.п., а не код, который будет внутриforEach
. Возможно, формулировка неудачная. Если подскажете лучший вариант формулировки - буду благодарен.
demimurych
19.08.2021 08:18Крайне противоречивое впечатление о материале. Создается впечатление, что это на скору руку собранный документ причем в разное время и разными людьми. Иногда описано то, что хочется перепроверить, а иногда откровенные фантазии человека который не дал себе труда докопаться до настоящих причин приводящих к тому или иному результату.
Судите сами:
Мутабельность
Первая проблема — плохая поддержка иммутабельности. Иммутабельность объектов означает генерацию новых объектов, иногда с довольно большой скоростью [...] убрать клонирование объектов и мутировать существующие объекты.
Давать рекомендации подобного характера в отрыве от платформы - это попросту сотрясать воздух. Потому делаем предположение что в данном случае речь идет о JS.
Первое чему учат людей, которые хотят писать высокопроизводительный код для JS это мономорфность функций и hidden class для обьектов. Благодаря которым V8 (и не только он) может применять эффективные методы оптимизации кода работы с обьектами. Как первое так и второе априори ставит под вопрос вменяемость совета о мутировании обьектов. Если Ваши обьекты мутируют, то вы не только проигрываете из-за того что не получаете эффективные оптимизации от v8, но и можете еще больше замедлить свой код в случаях, когда V8 примете решение о применении своих оптимизаций, а потом будет вынуждена проводить деоптимизации после ваших мутаций.
Zero memory allocation или GC-free
это безусловно очень правильное поведение при условии соблюдения того о чем говорится выше. То есть нужно не только использовать детерменированный пул обьектов, но пул обьектов с одинаковыми hidden классами. В противном случае Вы сократив издержки работы GC получите еще большие издержки в связи с тем, что к вашему коду откажутся применять хоть сколько нибудь эффективные оптимизации.
Чтобы подчеркнуть важность этого: разница в производительности при работе оптимизированного кода и кода который принято решение не оптимизировать, может составлять и 10 и 100 и более раз.
Потому по большому счету рекомендации которые даны в статье вредны, а что самое плохое делались человеком который не отдавал себе отчета в том, что рекомендовал.
map reduce filter etc...
Методы перечисленный выше не зря называют функциональными. Эти методы существую для реализации парадигмы функционального программирования, но не для бездумного применения map в качестве итератора по массиву. Ваша проблема, и проблема многих других, в том что вы и они понятия не имеют зачем map возвращает новый массив, для чего придуман reduce, и почему for никогда не будет быстрее forEach и что самое главное будет ему проигрывать в некоторых случаях в десятки раз.
Функциональное программирование, это парадигма со своими правилами, которые позволяют использовать математический аппарат позволяющий без анализа самого кода функции сделать вывод и ее идентичности другой функции, что в свою очередь позволяет JIT или компилятору применять к такому коду оптимизации вплоть до автоматического распаралеливания работы по разным потокам если они есть.
Всем кто хочет познакомиться с этой областью программирования рекомендую на youtube поискать лекции Виталия Брагилевского. Для того чтобы хотя бы получить представление о том, где когда и как нужно использовать функциональные методы.
Антипаттерн: накопление строк в массиве
Ещё один антипаттерн со времён шестого Internet Explorer — если нужно накопить длинную строку из кусочков, некоторые разработчики до сих пор сначала собирают эти строки в массив, чтобы потом вызвать join
это работало и работает по сей день и не только в IE. Большая проблема хипстеров пишущих микробенчмарки сравнивая тот или иной код в том, что они не понимают что сравнивают. Если я попрошу Вас сейчас набросать мне концепт такого теста, вы наверняка вляпаетесь в ровно теже проблемы что и все прочие, когда делали выводы либо о работе этого метода либо о противоположном результате. Я так категоричен потому, что ваша рекомендация о ConsString и рассказ легенды о IE прямо указывает на то, что Вы как им многие другие не понимали почему холивары на тему concat и + не утихали долгое время.
А проблема всегда была в том, что в большинстве случаев сравнивалось не время конкатенации, а время выделения ресурсов для обслуживания массива, и ресурсов для обслуживания строки состоящей из чанков. И как только тот же тест переписывается с исключением этой проблемы ( как самый простой способ - массив накапливающий строки должен иметь строго детерменированную длину равную количеству чанков) то результат внезапно становится совершенно другим. Когда вдруг array.concat оказывается быстрее ConsString
Вы безусловно правы в том, что в данном случае, это именно антипаттерн. Но обьяснение которое вы даете имеет мало общего с реальностью, и только лишний раз вводит людей в заблуждение относительно причин использования тех или иных методов и следствий такового использования.
Idle Until Urgent
Все верно с одним важны НО. Все таже мономорфность и все те же хидден классы. Если Ваш обьект используется раз за весь цикл работы кода, то можно делать и так как рекомендуют в статье. Но если Ваш обьект принимает активное участие в работе то есть обращение к нему происходит сотни раз, то реализация из статьи это похороны возможным оптимизациям со стороны V8. По причине того, что первое правило производительного кода JS это созданный обьект не должен изменять своей структуры.
Предсказание ветвлений (Branch prediction)
Совершенно верно то, что на уровне процессора существует масса оптимизаций кода в том числе и ветвлений. Только позвольте вам задать вопрос - вы вообще в курсе каким образом работает V8?
Вы отдаете себе отчет в том, что большая часть вашего кода никогда не будет собрана в машинный код, то есть будет исполняться на уровне байткода Ignition, на поведение которого и тем более оптимизации под архитектуру процессора Вы никак повлиять не сможете.
Вы отдаете себе отчет в том, что только в том случае если Ваш код попал в TurboFan вы можете предсказать его конкретную реализацию под конкретную архитектуру?
Даже я, человек который не вылазит из дизасембелра для двух архитектур далеко не всегда могу дать верный прогноз относительно того кода который выдаст turboFan.
И теперь для того чтобы ситуацию сделать еще более драматичной, для текущей x86 архитектуры, описание алгоритма оптимизации ветвлений на уровне процессора занимает почти 11 страниц 10 шрифтом. То есть, это оптимизации такого рода, которые ну совсем не для того кто пишет JS код. Но для того кто пишет оптимизирующие алгоритмы TurboFan.
Ровно по этим же причинам Ваши рекомендации относительно Доступ к памяти: направление итерации яйца выйденного не стоят. Даже если вынести за скобки все прохладные истории описанные выше, количество издержек накладываемых платформой которая выполняет ваш JS код в миллионы раз превысят потенциальные выгоды от учета архитектуры работы с памятью.
Единственная правильная рекомендация для таких случаев это - пишите код таким образом чтобы он был максимально предсказуем. То есть содержал минимум ветвлений и переходов. Это не только возможно но и правильно. Кому нужно сэкономить время прочитайте книжки классиков Кнут - искусство программирования, или Дэйкстру.
Даунгрейд кода: ES6 → ES5
Ноги таких рекомендация растут из ситуации которую создали программисты V8 в то время, когда реализация многих новых методов делалась как простой полифил написанный на JS. То есть это был ровно такой же JS код, как если бы он был написан самостоятельно, с той лишь разницей что он проходил все тесты. То есть полностью соответствовал спецификации с учетом всех пограничных случаев. Стоит ли говорить, что когда Вы написали свой код упрощая его до нельзя он работал быстрее?
В настоящий момент, насколько мне не изменяет память, в ES6 не осталось ни одной подобной функции.
И тут я должен высказать большое ФЭ в сторону процесса разработки V8. Ситуация подобная с деструктурирующим присваиванием, или теми же map reduce и прочими методами, сильно подорвали веру в то, что Вы как разработчики V8 всегда делаете все хотя бы на троечку. Отчего все ваши последющие увещевания, не использовать специфические оптимизации можно спустить куда подальше, именно потому, что не Вам решать как должен работать мой код, тем более в ситуациях когда вы скармливаете людям откровенную халтуру. Пусть и исправляемую в последствии. Потому как именно Вы являетесь причиной того, что даются советы о даунгреде es5 с целью увеличения производительности в 2021 году.
Игого
Из всей статьи, в некотором смысле можно оставить только часть касающуюся работы с алгоритмами. Все прочие рекомендации представляют из себя либо действительно работавшие на момент релиза хаки (как в случае с даунгрейдом) либо невесть откуда взявшиtся рекомендации напрямую противоречащие тому, как работает V8 даже 10 летней давности. (V8 взят за основу как подавляюще доминирующая платформа выполнения JS. Многие из вещей заявленных мной выше характерны и для CoreJs и для SpiderMonkey)
Рекомендации
Самой верной рекомендацией на текущий момент для людей которые не любят профилировать свой код, или не хотят разбираться в кишках V8 это писать код который был бы максимально простым и предсказуемым, следуя простым правилам: никогда не меняйте структуру обьектов. Пишите код так как будто он у вас типизирован. Иными словами создав ссылку на область данных (переменную) где лежит число, сделайте так чтобы там никогда ничего кроме таких же числе не появлялось.
Всегда помните, 90% всех оптимизаций на уровне V8 делаются для функций. Потому от того, насколько предсказуемы ваши функции напрямую зависит произвоидтельность вашего кода. Если у функции есть параметры, сделайте так, чтобы в эти параметры попадали всегда значения одного и того же типа.
Если есть что-то, что может сделать за вас платформа - пусть это делает она. Например не стоит городить циклы for там, где с этим прекрасно справиться forEach. Внимательно читайте документацию и пытайтесь представить что означает то или иное действие: применяя map к массиву вы создаете новый массив, что неизбежно ведет к издержкам на обслуживание этой структуры данных. То есть каждая операция чего то стоит, и нужно понимать чего.
И дайте себе труд посмотреть пару лекций на эту тему от людей которые понимают то о чем говорят. Например
Franziska Hinkelmann https://www.youtube.com/watch?v=p-iiEDtpy6I&list=WL&index=148
или Вячеслава Егорова https://www.youtube.com/watch?v=HPFARivHJRY
teenspirit1
23.08.2021 14:46Добрый день, я попытался воспроизвести ваш пример из пункта Антипаттерн: накопление строк в массиве , где вы предлагаете задать длину массива и в этом случае код должен исполнится быстрее. Не могли бы вы показать пример ?
В моем случае конкатенация строк по прежнему исполнялась в разы быстрее.
victor-homyakov Автор
23.08.2021 15:48Мутабельность
Давать рекомендации подобного характера в отрыве от платформы ... Потому делаем предположение что в данном случае речь идет о JS.
Не надо делать предположение, надо прочитать параграф перед пунктом "Мутабельность": "Эта группа оптимизаций подходит для языков, в которых есть garbage collection и автоматическое управление памятью: JavaScript, C#, Java и некоторых других".
В статье я указал область применимости данной оптимизации: "Иммутабельность объектов означает генерацию новых объектов, иногда с довольно большой скоростью. А старые объекты должны собираться через garbage collector. В горячем коде это может очень сильно влиять на скорость работы. Именно затраты на сборку мусора могут превышать затраты на работу вашего кода." То есть мы уже попрофилировали наш прод и видим, что упираемся в память и сборщик мусора. В такой ситуации оптимизация работы с памятью - первоочередная вещь, и я даю конкретный совет, на что обратить внимание (на иммутабельность) и как это можно исправить (мутировать существующий объект).
Далее. Когда мутирование существующего объекта может создавать новый hidden class и мешать мономорфизму? Когда оно создаёт в объекте новые поля (или удаляет существующие, но с удалением полей всё ещё хуже и это тема отдельного длинного разговора). Меняя значения уже существующего поля так, чтобы тип поля не менялся, мы не создаём нового hidden class. И наоборот, поддерживая иммутабельность объектов, легко наступить на полиморфизм.
Резюмируя замечания по пункту "Мутабельность":
рекомендация не в отрыве от платформы; наоборот, я обратил внимание, для каких платформ и когда именно данная рекомендация применима
рекомендация ущерба мономорфизму не наносит: если в коде создаётся новое поле, то новый hidden class надо создавать в любом случае, а если меняется существующее поле, то как минимум хуже не станет
victor-homyakov Автор
23.08.2021 16:06Zero memory allocation или GC-free
Чтобы подчеркнуть важность этого: разница в производительности при работе оптимизированного кода и кода который принято решение не оптимизировать, может составлять и 10 и 100 и более раз.
Такие утверждения желательно сопровождать ссылками на бенчмарк.
Антипаттерн: накопление строк в массиве
И как только тот же тест переписывается с исключением этой проблемы ( как самый простой способ - массив накапливающий строки должен иметь строго детерменированную длину равную количеству чанков) то результат внезапно становится совершенно другим. Когда вдруг array.concat оказывается быстрее ConsString
Я писал про join, а не concat.
[grammar]Детерминированную[/grammar]
Желательно всё-таки показать правильный бенчмарк. И заодно научить, как правильно писать этот же код в реальном проекте, ибо строго детерминированную длину массива я в коде с join видел крайне редко, а иногда длина массива в принципе неизвестна заранее.
victor-homyakov Автор
23.08.2021 16:20Idle Until Urgent
Все верно с одним важны НО. Все таже мономорфность и все те же хидден классы. [...] созданный обьект не должен изменять своей структуры.
Ещё раз внимательно перечитал пример кода
constructor() { this.formatter = new IdleValue(() => new Intl.DateTimeFormat(…)); } handleUserClick() { const formatter = this.formatter.getValue(); this.clickTime = formatter.format(new Date()); }
Не вижу связи между ленивой инициализацией
IdleValue
и созданием hidden class. Да, здесь, как и в оригинальном коде без ленивой инициализации, не в конструкторе создаётся полеthis.clickTime
, но при чём здесьIdleValue
? Можем переписать этот пример так:constructor() { this.formatter = new IdleValue( () => new Intl.DateTimeFormat(…)); this.clickTime = ''; } handleUserClick() { const formatter = this.formatter.getValue(); this.clickTime = formatter.format(new Date()); }
Покажите, как в таком коде использование
IdleValue
ломает мономорфизм?
victor-homyakov Автор
23.08.2021 17:45Предсказание ветвлений (Branch prediction)
Вы отдаете себе отчет в том, что большая часть вашего кода никогда не будет собрана в машинный код, то есть будет исполняться на уровне байткода Ignition, на поведение которого и тем более оптимизации под архитектуру процессора Вы никак повлиять не сможете.
Всё правильно. Большая часть кода никогда не попадёт в компилятор. Но нас и не интересует абсолютно весь код. Мы не занимаемся преждевременной оптимизацией всей кодовой базы - нас интересуют только обнаруженные узкие места, и для них предположение "никогда не будет собрана в машинный код, то есть будет исполняться на уровне байткода" скорее всего неверно.
Ровно по этим же причинам Ваши рекомендации относительно Доступ к памяти: направление итерации яйца выйденного не стоят. Даже если вынести за скобки все прохладные истории описанные выше, количество издержек накладываемых платформой которая выполняет ваш JS код в миллионы раз превысят потенциальные выгоды от учета архитектуры работы с памятью.
Переформулирую другими словами то, о чём я писал в статье:
когда есть конкретное место в коде, где мы обрабатываем большие массивы данных (картинки, звук и т.п.)
и когда профилирование показало нам, что в это месте явная проблема в скорости
только тогда мы можем попробовать применить указанные оптимизации и обязательно проверить и в бенчмарках и в продакшне, будет ли от них заметное ускорение
и ускорение в некоторых случаях действительно наблюдается в продакшне
faiwer
02.09.2021 00:43и почему for никогда не будет быстрее forEach и что самое главное будет ему проигрывать в некоторых случаях в десятки раз.
Возможно я вас неправильно понял, но какие оптимизации могут сделать
foreach
быстрее, чемfor
? Мы всё ещё про JS массивы? Речь идёт о каких-то вырожденных случаях с "дырками"? Или про нестандартныйforeach
с ранним выходом (как вlodash
)?Под
for
подразумеваетсяfor (let idx = 0, count = arr.length; idx < count; ++ idx) { const item = arr[idx]; ... }
? Или что-то более монструозное?P.S. Касательно мутаций — автор явно имел ввиду не мутацию схемы hidden-class-а, а мутацию значений конкретных полей.
Aetae
23.08.2021 14:46Осталось написать babel transform который всё это сделает за нас и можно будет спасть спокойно. Не возьмётесь?)
P.S.
->array.some(x => Boolean(x))
:)array.some(Boolean)
Akuma
Но если нам надо "вычислить корень из первых пяти элементов", то зачем вообще такое писать?
И все, не будет у вас разницы с лодашем и не нужно его тянуть.
victor-homyakov Автор
Разница всё равно будет -
filter
обработает весь исходный массив, и создаст массив-результат, в котором всё равно будет больше элементов, чем нужно. То есть останется сложность O(N) и избыточное потребление памяти.walkman7
Проблема именно здесь
Если элементов 10000 то
filter
пройдется по всему массиву все равно. Аlodash
сделает это более эффективно и при первых пяти элементах которые пройдут фильтр перейдет кmap
. Если я правильно понял.Akuma
Да, верно, я думал внимание в статье обращено именно к map().
Впрочем, все равно нет особого смысла подключать внешнюю библиотеку, если уж мы экономим на таких вещах. А то получается экономия путем подключения оверхеда.
victor-homyakov Автор
Всё так. Если Lodash на клиенте приводит к слишком большому оверхеду, но код нужно ускорить - я сделаю так же. Если Lodash уже есть - можно написать на нём.
Здесь для меня главное было обратить внимание на саму концепцию - мы можем писать одновременно достаточно эффективный и легко читаемый и сопровождаемый код.
Zibx
Lodash ускорит написание кода разработчиком, но тоже даст оверхэд в виде создания массива выполняемых операций, инициализации скоупа при вызове функций. Сложность остаётся O(N) (хотя тут тоже можно уйти в дебри того что находится под капотом у push и что sqrt хорошо бы подтянуть чуть ближе чтоб не искать Math в global), но простой цикл остаётся всегда быстрее. На малых объёмах я тоже ленюсь.
victor-homyakov Автор
lodash выполнит свой код
примерно так:
Выполняем filter, пока не нашлось подходящего элемента или не закончился входной массив. Если закончился входной массив - goto 5.
Для найденного элемента вычисляем корень
Запоминаем результат в выходном массиве
В выходном массиве набралось 5 элементов? Если нет - goto 1.
Возвращаем выходной массив.
Zibx
Кстати, в случае использования на фронте типа яндекса можно пойти чуть дальше и соорудить препроцессор который будет подобные конструкции раскладывать в циклы с ифами и тем самым убирать накладные расходы на всю внутреннюю логику лодаша и создания\чистки контекстов в процессе. А если ещё и дифф алгоритм реакта уберёте, то можно будет на конференциях рассказывать о вкладе компании в борьбу с CO<sub>2</sub>