Паттерны оптимизации JavaScript. Часть 1

Перевод статьи Benedikt Meurer: JavaScript Optimization Patterns(Part 1).

Прошло время с моего последнего сообщения в блоге, главным образом из-за того, что мне не хватало времени или сил, чтобы сесть и написать все, что я хотел рассказать. Частично это было потому, что я был очень занят запуском Ignition и TurboFan в Chrome 59, что, к счастью, завершилось успехом. Частично из-за того, что я хотел провести время с семьей. И последнее, но не менее важное, я участвовал в JSConf EU и Web Rebels, и на момент написания этой статьи я нахожусь на enterJS, прокрастинируя вместо отшлифовки последних правок в моём докладе.

Тем временем я только что вернулся с очень интересного обеденного обсуждения с Брайаном Терлсоном, Адой Роуз Эдвардс и Эшли Уильямс о хороших подходах к оптимизации JavaScript, которые мы можем дать в качестве совета, и в частности, о том, как трудно их вывести. Один конкретный вывод, который я сделал, заключался в том, что идеальная производительность часто зависит от контекста, в котором работает код, и это часто самая сложная часть. Поэтому я подумал, что, вероятно, стоит поделиться этой информацией со всеми. Я начну это как серию сообщений в блоге. В этой первой части я попытаюсь выделить влияние, которое может оказать конкретный контекст выполнения на производительность вашего JavaScript-кода.

Рассмотрим следующий искусственный класс Point, имеющий метод distance, который вычисляет «манхэттенское расстояние» между двумя точками.

class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
distance(other) {
const dx = Math.abs(this.x - other.x);
const dy = Math.abs(this.y - other.y);
return dx + dy;
}
}

В дополнение к этому рассмотрим следующую тестирующую функцию, создающую пару экземпляров Pointи вычисляющую расстояние между ними несколько миллионов раз, суммируя результат (да, я знаю, что это микро-бенчмарк, но потерпите немножко):

function test() {
const points = [
new Point(10, 10),
new Point(1, 1),
new Point(8, 9)
];
let result = 0;
for (let i = 0; i < 10000000; ++i) {
for (const point1 of points) {
for (const point2 of points) {
result += point1.distance(point2);
}
}
}
return result;
}

Теперь у нас есть правильный бенчмарк для класса Point и, в частности, его метод distance. Давайте проведём несколько запусков тестирующей функции, чтобы узнать, что такое производительность, используя следующий HTML-сниппет:

<script>
function test() {
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
distance(other) {
const dx = Math.abs(this.x - other.x);
const dy = Math.abs(this.y - other.y);
return dx + dy;
}
}
const points = [
new Point(10, 10),
new Point(1, 1),
new Point(8, 9)
];
let result = 0;
for (let i = 0; i < 10000000; ++i) {
for (const point1 of points) {
for (const point2 of points) {
result += point1.distance(point2);
}
}
}
return result;
}
for (let i = 1; i <= 5; ++i) {
console.time("test " + i);
test();
console.timeEnd("test " + i);
}
</script>

Если вы запустите это в Chrome 61 (Canary), в консоли Chrome Developer Tools вы увидите следующий вывод:

test 1: 595.248046875ms
test 2: 765.451904296875ms
test 3: 930.452880859375ms
test 4: 994.2890625ms
test 5: 3894.27392578125ms

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

<script>
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
distance(other) {
const dx = Math.abs(this.x - other.x);
const dy = Math.abs(this.y - other.y);
return dx + dy;
}
}
function test() {
const points = [
new Point(10, 10),
new Point(1, 1),
new Point(8, 9)
];
let result = 0;
for (let i = 0; i < 10000000; ++i) {
for (const point1 of points) {
for (const point2 of points) {
result += point1.distance(point2);
}
}
}
return result;
}
for (let i = 1; i <= 5; ++i) {
console.time("test " + i);
test();
console.timeEnd("test " + i);
}
</script>

Если мы немного изменим фрагмент кода таким образом, чтобы класс Point был определен вне тестовой функции, мы получим другие результаты:

test 1: 598.794921875ms
test 2: 599.18115234375ms
test 3: 600.410888671875ms
test 4: 608.98388671875ms
test 5: 605.36376953125ms

Теперь производительность довольно стабильна с небольшим шумом. Обратите внимание, что в обоих случаях мы использовали точно такой же код для класса Point и точно такой же код для логики тестового драйвера. Единственное различие заключается в том, где именно мы размещаем класс Point в коде.

Также стоит отметить, что это не связанно с новым синтаксисом class ES2015: при использовании старого ES5-ситаксиса для класса Point мы получим такие же результаты.

function Point(x, y) {
this.x = x;
this.y = y;
}
Point.prototype.distance = function (other) {
var dx = Math.abs(this.x - other.x);
var dy = Math.abs(this.y - other.y);
return dx + dy;
}

Основная причина разницы в производительности, когда объявление класса Point расположено внутри функции test, заключается в том, что литерал class выполняется несколько раз (ровно 5 раз в моем примере выше), тогда как если он расположен вне функции test, он выполняется только один раз. Каждый раз, когда выполняется определение класса, создается новый объект-прототип, содержащий все методы класса. В дополнение к этому создается новый конструктор, соответствующий классу и имеющий объект прототипа, заданный как свойство prototype.

Новые экземпляры класса создаются с использованием этого свойства prototype в качестве объекта прототипа. Но так как V8 отслеживает прототип экземпляра как часть формы объекта или скрытого класса (см. раздел «Setting up prototypes in V8»), чтобы оптимизировать доступ к свойствам в цепочке прототипов, наличие разных прототипов автоматически подразумевает наличие разных форм у этих объектов. И как таковой сгенерированный код становится все более полиморфным с каждым новым определение класса, и, в конце концов, V8 отказывается от полиморфизма после того, как он видит более четырёх различных форм объектов и входит в так называемое мегаморфное состояние, что означает отказ от генерации высоко оптимизированного кода.

Таким образом, вывод из этого упражнения: идентичный код, помещенный в другое место, может легко привести к разнице в производительности в 6,5 раз! Это особенно важно, потому что популярные платформы для бенчмарков и сайты, такие как esbench.com, как правило, выполняют код в другом контексте, чем ваше приложение (например, код вспомогательных функций-обёрток, запускающихся несколько раз), и, таким образом, результаты бенчмарков могут ввести в сильное заблуждение.


Слушайте наш подкаст в iTunes и SoundCloud, читайте нас на Medium, контрибьютьте на GitHub, общайтесь в группе Telegram, следите в Twitter и канале Telegram, рекомендуйте в VK и Facebook.

Статья на GitHub

Like what you read? Give Andrey Melikhov a round of applause.

From a quick cheer to a standing ovation, clap to show how much you enjoyed this story.