Исследование и оптимизация производительности Object#toString в ES2015

Исследование и оптимизация производительности Object#toString в ES2015

Бенедикт Мейрер из мюнхенского офиса Google занимается вопросами оптимизации JavaScript. В этом материале он рассказывает об особенностях реализации и функционирования Object.prototype.toString() в движке V8. В частности, речь пойдёт о том, почему эта конструкция важна, о том, как она изменилась с появлением символов ES2015, и о подходе к оптимизации, который предложили инженеры из Mozilla, приведшем к примерно шестикратному увеличению производительности toString() в V8.

Введение

В стандарте ECMAScript 2015 появилась концепция так называемых известных символов (well-known symbols). Это — специальные встроенные символы, которые представляют внутренние механизмы языка, недоступные разработчикам в реализациях ECMAScript 5 и предыдущих версий стандарта.

Вот несколько примеров:

    : это метод, который возвращает итератор объекта, используемый по умолчанию. Он применяется в таких конструкциях языка, как for..of, yield*, оператор расширения, деструктурирующее присваивание, и в других. : метод для определения того, считает ли объект-конструктор некий объект своим экземпляром. Используется оператором instanceof. : строковое значение, используемое для описания объекта, применяемое по умолчанию. К нему обращается метод Object.prototype.toString().

Один из особенно интересных примеров этого — новый символ Symbol.toStringTag, который используется для управления поведением встроенного метода Object.prototype.toString(). Например, теперь разработчик может поместить особое свойство в любой экземпляр объекта, после чего это свойство будет использоваться вместо стандартного встроенного тега при вызове метода toString :

Для этого требуется, чтобы реализация Object.prototype.toString() для ES2015 и более поздних версий стандарта сначала конвертировала его значение this в объект с помощью абстрактной операции ToObject, а затем выполняла поиск Symbol.toStringTag в полученном объекте и в его цепочке прототипов. Вот что об этом можно найти в соответствующей части спецификации языка:

Фрагмент спецификации, посвящённый Object.prototype.toString ()

Тут можно видеть, во-первых, преобразование с использованием ToObject, а во-вторых — вызов Get для @@toStringTag (это — особый внутренний синтаксис языковой спецификации для известного символа с именем toStringTag ). Добавление конструкции Symbol.toStringTag в ES2015 значительно расширяет возможности разработчиков, но, в то же время, означает и определённые затраты ресурсов.

Цель исследования производительности toString

Производительность метода Object.prototype.toString() в Chrome и Node.js уже исследовалась, так как этот метод интенсивно используется, для проверки типов, некоторыми популярными фреймворками и библиотеками. Так, фреймворк AngularJS использует этот метод для реализации различных вспомогательных функций, среди которых angular.isDate, angular.isArrayBuffer, и angular.isRegExp. Например:

Кроме того, популярные библиотеки, такие, как lodash и underscore.js, используют Object.prototype.toString() для реализации проверок значений. Так, например, устроены предикаты _.isPlainObject и _.isDate из lodash:

Инженеры из Mozilla, работающие над JavaScript-движком SpiderMonkey, выяснили, что операция поиска Symbol.toStringTag в Object.prototype.toString() является узким местом производительности реальных приложений. Этот вывод был сделан в ходе исследования бенчмарка Speedometer. Запустив только подтест AngularJS из Speedometer с использованием внутреннего профилировщика V8 (для того, чтобы его включить, нужно передать ключ командной строки --no-sandbox --js-flags=--prof при запуске Chrome), мы обнаружили, что значительная часть времени тратится на выполнение поиска @@toStringTag (в GetPropertyStub ) и на выполнение кода ObjectProtoToString , который реализует встроенный метод Object.prototype.toString() :

Профилирование подтеста AngularJS из бенчмарка Speedometer

Ян де Мойж из команды разработчиков SpiderMonkey создал простой микробенчмарк для проверки производительности Object.prototype.toString() в массивах:

На самом деле, выполнение этого микробенчмарка с использованием внутреннего профилировщика, встроенного в V8 (включить его в оболочке d8 можно с помощью ключа командной строки --prof ), уже показало суть проблемы. Основные ресурсы тратятся на поиск Symbol.toStringTag в массиве [1, 2, 3] . Примерно 73% общего времени выполнения уходит на не дающий результата поиск свойства (в функции GetPropertyStub , которая реализует универсальный поиск свойств), ещё 3% тратятся во встроенной функции ToObject , которая, в случае с массивами, является пустой операцией (массивы, с точки зрения JavaScript, уже являются объектами).

Исследование микробенчмарка, разработанного в Mozilla, с помощью профилировщика (до оптимизации)

Интересные символы

Для SpiderMonkey было предложено решение вышеописанной проблемы, которое заключается в добавлении к объектам так называемого интересного символа (interesting symbol). Этот символ является свойством любого скрытого класса, сообщающим о том, могут ли объекты с этим скрытым классом иметь свойство с именем @@toStringTag или @@toPrimitive . Благодаря такому подходу ресурсоёмкого поиска Symbol.toStringTag можно, в общем случае, избежать, так как этот поиск всё равно не даёт результатов. Реализация этого предложения привела к примерно двукратному росту производительности микробенчмарка с массивом для SpiderMonkey.

Так как я исследовал некоторые варианты использования AngularJS, я счёл, что мне очень повезло найти эту идею, и решил попробовать реализовать это в V8. Я начал размышлять над архитектурой решения, и, в итоге, портировал его на V8, пока ограничившись лишь Symbol.toStringTag и Object.prototype.toString() . Дело в том, что я не нашёл (пока не нашёл) свидетельств того, что Symbol.toPrimitive — это важный источник неприятностей в Chrome или Node.js. Основная идея тут заключается в том, что, по умолчанию, мы полагаем, что экземпляры объектов не имеют интересных символов , а каждый раз, когда мы добавляем новое свойство к экземпляру, проверяем, является ли имя этого свойства подобным символом. Если это так, мы устанавливаем определённый бит в скрытых классах экземпляров объектов.

Взгляните на этот простой пример. Тут объект obj начинает существование, не обладая интересным символом . Поэтому вызов Object.prototype.toString() идёт по новому, быстрому, пути выполнения, когда поиск Symbol.toStringTag можно пропустить (это именно так ещё и потому, что Object.prototype также не имеет интересного символа ). Второй вызов выполняет обычную медленную операцию поиска, так как у obj теперь есть интересный символ .

Результаты оптимизации

Реализация этого механизма в V8 улучшила производительность вышеописанного микробенчмарка примерно в 5.8 раза. Испытания проводились под Linux, на рабочей станции HP Z620. Проверив производительность с помощью профилировщика, мы можем видеть, что программа больше не тратит время в GetPropertyStub . Вместо этого основную нагрузку на систему создаёт, как и ожидается, встроенный метод Object.prototype.toString() .

Исследование микробенчмарка, разработанного в Mozilla, с помощью профилировщика (после оптимизации)

Мы также провели испытания оптимизированного движка с помощью бенчмарка, который немного ближе к реальности. При проведении замеров производительности Object.prototype.toString() передаются разные значения, включая примитивы и объекты, у которых есть специально установленное свойство Symbol.toStringTag . В результате последняя версия V8 оказалась в 6.5 раза быстрее, чем V8 6.1.

Результат выполнения нового микробенчмарка на разных версиях V8

Измерение воздействия оптимизации на браузерный бенчмарк Speedometer, и, в частности, на подтест AngularJS, показало рост скорости по всем тестам на 1% и убедительный рост на 3% при выполнении подтеста AngularJS.

Воздействие оптимизации на бенчмарк Speedometer

Итоги

Даже высокооптимизированные встроенные функции JavaScript, вроде Object.prototype.toString() , всё ещё обладают потенциалом дальнейшей оптимизации. В частности, оптимизация, описанная выше, позволяет повысить производительность до 6.5 раз. В этом можно убедиться, если достаточно сильно углубиться в результаты выполнения различных тестов производительности, вроде подтеста AngularJS из бенчмарка Speedometer.

Мне хотелось бы поблагодарить Яна де Мойжа и Тома Шустера за их исследования и за отличную идею с интересными символами .

Стоит отметить, что JavaScriptCore — движок JavaScript, используемый WebKit, кэширует результаты последовательных вызовов Object.prototype.toString() скрытого класса экземпляра объекта (этот кэш появился в начале 2012-го, до выхода спецификации ES2015). Это — очень интересная стратегия, но область её применения ограничена (то есть, она бесполезна в применении к другим известным символам таким, как Symbol.toPrimitive или Symbol.hasInstance). Кроме того, она требует очень сложной логики инвалидации кэша для обеспечения своевременной реакции на изменения в цепочке прототипов. Именно поэтому, по крайней мере, на данный момент, я и сделал выбор не в пользу решения для V8, основанного на кэше.

Уважаемые читатели! Профилируете ли вы свои JavaScript-приложения? Как по-вашему, какие стандартные механизмы JS, реализованные в V8, нуждаются в оптимизации?

📎📎📎📎📎📎📎📎📎📎