почему вы должны знать, как работает двигатель

1656585017 pochemu vy dolzhny znat kak rabotaet dvigatel

автор Райнер Ганекамп

4wL8qglwaRdNUHXCiXjKdR8aHSgJAKdtwEBB
Фото Moto “Club4AG” Мива на Flickr

Эта статья также доступна на испанском языке.

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

Ниже вы увидите однострочную функцию, возвращающую свойство lastName переданного аргумента. Просто добавив одно свойство к каждому объекту, мы в конечном счете потеряем производительность более чем на 700%!

Как я объясню подробно, отсутствие у JavaScript статических типов является причиной такого поведения. Однажды рассматриваться как преимущество перед другими языками, такими как C# или Java, оказывается более «фаустовским соглашением».

Торможение на полной скорости

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

Прекрасно!

Пусть другие выполняют тяжелую работу. Зачем беспокоиться о том, как работают двигатели?

В нашем примере кода ниже у нас есть пять объектов, которые хранят имена и фамилии персонажей «Звездных войн». Функция getName возвращает значение фамилии. Мы измеряем общее время, за которое эта функция выполняется 1 млрд раз:

(() => {   const han = {firstname: "Han", lastname: "Solo"};  const luke = {firstname: "Luke", lastname: "Skywalker"};  const leia = {firstname: "Leia", lastname: "Organa"};  const obi = {firstname: "Obi", lastname: "Wan"};  const yoda = {firstname: "", lastname: "Yoda"};  const people = [    han, luke, leia, obi,     yoda, luke, leia, obi   ];  const getName = (person) => person.lastname;
  console.time("engine");  for(var i = 0; i < 1000 * 1000 * 1000; i++) {     getName(people[i & 7]);   }  console.timeEnd("engine"); })();

На Intel i7 4510U время исполнения составляет примерно 1,2 секунды. Всё идет нормально. Теперь мы добавляем еще одно свойство каждому объекту и выполняем его снова.

(() => {  const han = {    firstname: "Han", lastname: "Solo",     spacecraft: "Falcon"};  const luke = {    firstname: "Luke", lastname: "Skywalker",     job: "Jedi"};  const leia = {    firstname: "Leia", lastname: "Organa",     gender: "female"};  const obi = {    firstname: "Obi", lastname: "Wan",     retired: true};  const yoda = {lastname: "Yoda"};
  const people = [    han, luke, leia, obi,     yoda, luke, leia, obi];
  const getName = (person) => person.lastname;
  console.time("engine");  for(var i = 0; i < 1000 * 1000 * 1000; i++) {    getName(people[i & 7]);  }  console.timeEnd("engine");})();

Сейчас наше время выполнения составляет 8,5 секунды, что примерно в 7 раз медленнее нашей первой версии. Это похоже на нажатие на тормоз на полной скорости. Как это могло произойти?

Пора внимательнее рассмотреть двигатель.

Объединенные силы: интерпретатор и компилятор

Двигатель – это часть, которая читает и выполняет исходный код. Каждый крупный поставщик браузеров имеет свой собственный двигатель. Mozilla Firefox имеет Spidermonkey, Microsoft Edge имеет Chakra/ChakraCore, а Apple Safari называет свой механизм JavaScriptCore. Google Chrome использует V8, также двигатель Node.js.
Выпуск V8 в 2008 г. стал переломным моментом в истории двигателей. V8 заменил относительно медленную интерпретацию JavaScript в обозревателе.

Причина этого огромного усовершенствования в основном заключается в сочетании интерпретатора и компилятора. Сегодня все четыре двигателя используют эту технику.
Интерпретатор выполняет исходный код почти сразу. Компилятор генерирует машинный код, который пользовательская система выполняет непосредственно.

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

Основная идея современных двигателей состоит в том, чтобы соединить лучшее из обоих миров:

  • Быстрый запуск программы интерпретатора.
  • Быстрое исполнение компилятора.
xcTig2PibioQS1T5oImy5exOvcn43uda9R42
Современный двигатель использует интерпретатор и компилятор. Источник: imgflip

Достижение обеих целей начинается с переводчика. Параллельно двигатель обозначает часто выполняемые части кода в качестве «горячего пути» и передает их компилятору вместе с контекстной информацией, собранной во время выполнения. Этот процесс позволяет компилятору адаптировать и оптимизировать код для контекста.

Мы называем поведение компилятора «точно вовремя» или просто JIT.
Когда двигатель работает хорошо, вы можете представить некоторые сценарии, где JavaScript даже превосходит C++. Неудивительно, что большинство работы двигателя уходит на эту «контекстуальную оптимизацию».

bu47j9z9Dee3O3hHm51ijuARr3aUTtj4j4EH
Взаимодействие между интерпретатором и компилятором

Статические типы при выполнении: встроенное кэширование

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

Механизм назначает каждому объекту тип, который он генерирует во время выполнения. V8 называет эти типы, которые не являются частью стандарта ECMAScript, скрытыми классами или формами объектов. Чтобы два объекта имели одинаковую форму объекта, оба объекта должны обладать совершенно одинаковыми свойствами в одном порядке. Следовательно, объект{firstname: "Han", lastname: "Solo"} будет отнесен к другому классу, чем {lastname: "Solo", firstname: "Han"}.

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

Встроенное кэширование устраняет операции поиска. Неудивительно, что это значительно улучшает производительность.

Возвращаясь к нашему предыдущему примеру: все объекты во время первого запуска обладали только двумя свойствами, firstname и lastname, в том же порядке. Скажем, внутреннее название этой фигуры объекта p1. Когда компилятор применяет IC, он предполагает, что функция получает только форму объектаp1 и возвращает значение lastname сейчас же.

o4aMw-H7fhu2dKraaGPkHqOiV0lMAJr7ks3j
Встроенное кэширование в действии (мономорфное)

Однако во втором запуске мы имели дело с 5 разными формами объектов. Каждый объект имел дополнительное свойство и yoda отсутствовал firstname полностью. Что происходит, когда мы имеем дело с несколькими объектами?

Промежуточные утки или несколько типов

Функциональное программирование имеет хорошо известную концепцию «утиного ввода», когда хорошее качество кода требует функций, которые могут обрабатывать несколько типов. В нашем случае, пока переданный объект имеет свойство фамилии, все в порядке.

Встроенное кэширование устраняет дорогостоящий поиск местоположения памяти свойства. Лучше всего работает, когда при каждом доступе к свойству объект имеет ту же форму объекта. Это называется мономорфным IC.

Если у нас до четырех различных форм объекта, мы находимся в полиморфном состоянии IC. Как и в мономорфном, оптимизированный машинный код «знает» уже все четыре места расположения. Но он должен проверить, к какой из четырех возможных форм объекта принадлежит переданный аргумент. Это приводит к понижению производительности.

Как только мы превысим порог в четыре, ситуация становится гораздо хуже. Сейчас мы находимся в так называемом мегаморфном IC. В этом состоянии больше нет локального кэширования мест памяти. Его нужно искать из глобального кэша. Это приводит к чрезвычайному падению производительности, которое мы видели выше.

Полиморфный и мегаморфный в действии

Ниже мы видим полиморфный встроенный кэш с 2 разными формами объектов.

lNpKqU5ShHJkat0PpKIahkpFAwPOHpCJRIiA
Полиморфный встроенный кэш

И мегаморфный IC из нашего примера кода с 5 разными формами объектов:

cDEESdQECGIz20LpuSYqgtUtjRCD1wD0hRPx
Мегаморфный встроенный кэш

Класс JavaScript в помощь

Ладно, мы имели 5 форм объектов и столкнулись с мегаморфной IC. Как мы можем это исправить?

Мы должны убедиться, что двигатель обозначает все 5 наших объектов как одинаковую форму. Это означает, что созданные нами объекты должны содержать все возможные свойства. Мы могли бы использовать объектные литералы, но я считаю, что классы JavaScript являются лучшим решением.

Для свойств, которые не определены, мы просто передаем null или оставьте его. Конструктор убедится, что эти поля инициализированы значением:

(() => {  class Person {    constructor({      firstname="",      lastname="",      spaceship = '',      job = '',      gender="",      retired = false    } = {}) {      Object.assign(this, {        firstname,        lastname,        spaceship,        job,        gender,        retired      });    }  }
  const han = new Person({    firstname: 'Han',    lastname: 'Solo',    spaceship: 'Falcon'  });  const luke = new Person({    firstname: 'Luke',    lastname: 'Skywalker',    job: 'Jedi'  });  const leia = new Person({    firstname: 'Leia',    lastname: 'Organa',    gender: 'female'  });  const obi = new Person({    firstname: 'Obi',    lastname: 'Wan',    retired: true  });  const yoda = new Person({ lastname: 'Yoda' });  const people = [    han,    luke,    leia,    obi,    yoda,    luke,    leia,    obi  ];  const getName = person => person.lastname;  console.time('engine');  for (var i = 0; i < 1000 * 1000 * 1000; i++) {    getName(people[i & 7]);  }  console.timeEnd('engine');})();

Когда мы снова выполняем эту функцию, мы видим, что время выполнения возвращается до 1,2 секунды. Работа выполнена!

Резюме

Современные двигатели JavaScript сочетают преимущества интерпретатора и компилятора: быстрый запуск программы и быстрое исполнение кода.

Встроенное кэширование является мощным методом оптимизации. Лучше всего работает, когда к оптимизированной функции переходит только одна форма объекта.

Мой яркий пример показал влияние разных типов встроенного кэширования и снижения производительности мегаморфных кэшей.

Использование классов JavaScript является хорошей практикой. Статические типизированные транспилеры, такие как TypeScript, увеличивают возможность мономорфных IC.

Дальнейшее чтение

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *