Бенедикт Мейрер из мюнхенского офиса Google занимается вопросами оптимизации JavaScript. В этом материале он рассказывает об особенностях реализации и функционирования Object.prototype.toString()
в движке V8. В частности, речь пойдёт о том, почему эта конструкция важна, о том, как она изменилась с появлением символов ES2015, и о подходе к оптимизации, который предложили инженеры из Mozilla, приведшем к примерно шестикратному увеличению производительности toString()
в V8.
Введение
В стандарте ECMAScript 2015 появилась концепция так называемых известных символов (well-known symbols). Это — специальные встроенные символы, которые представляют внутренние механизмы языка, недоступные разработчикам в реализациях ECMAScript 5 и предыдущих версий стандарта.
Вот несколько примеров:
- Symbol.iterator: это метод, который возвращает итератор объекта, используемый по умолчанию. Он применяется в таких конструкциях языка, как for..of, yield*, оператор расширения, деструктурирующее присваивание, и в других.
- Symbol.hasInstance: метод для определения того, считает ли объект-конструктор некий объект своим экземпляром. Используется оператором instanceof.
- Symbol.toStringTag: строковое значение, используемое для описания объекта, применяемое по умолчанию. К нему обращается метод Object.prototype.toString().
Большинство из этих новых конструкций комплексно и нетривиально воздействуют на различные части языка. Это ведёт к значительным изменениям в профиле производительности из-за так называемых обезьяньих патчей. Речь идёт о возможности изменения некоторых стандартных механизмов пользовательским кодом во время выполнения программы.
Один из особенно интересных примеров этого — новый символ Symbol.toStringTag, который используется для управления поведением встроенного метода Object.prototype.toString(). Например, теперь разработчик может поместить особое свойство в любой экземпляр объекта, после чего это свойство будет использоваться вместо стандартного встроенного тега при вызове метода toString
:
class A {
get [Symbol.toStringTag]() { return 'A'; }
}
Object.prototype.toString.call(‘’); // "[object String]"
Object.prototype.toString.call({}); // "[object Object]"
Object.prototype.toString.call(new A); // "[object A]"
Для этого требуется, чтобы реализация 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. Например:
/**
* @ngdoc function
* @name angular.isDate
* @module ng
* @kind function
*
* @description
* Determines if a value is a date.
*
* @param {*} value Reference to check.
* @returns {boolean} True if `value` is a `Date`.
*/
function isDate(value) {
return toString.call(value) === '[object Date]';
}
Кроме того, популярные библиотеки, такие, как lodash и underscore.js, используют Object.prototype.toString()
для реализации проверок значений. Так, например, устроены предикаты _.isPlainObject и _.isDate из lodash:
/**
* Checks if `value` is classified as a `Date` object.
*
* @since 0.1.0
* @category Lang
* @param {*} value The value to check.
* @returns {boolean} Returns `true` if `value` is a date object, else `false`.
* @example
*
* isDate(new Date)
* // => true
*
* isDate('Mon April 23 2012')
* // => false
*/
function isDate(value) {
return isObjectLike(value) && baseGetTag(value) == '[object Date]'
}
Инженеры из 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()
в массивах:
function f() {
var res = "";
var a = [1, 2, 3];
var toString = Object.prototype.toString;
var t = new Date;
for (var i = 0; i < 5000000; i++) res = toString.call(a);
print(new Date - t);
return res;
}
f();
На самом деле, выполнение этого микробенчмарка с использованием внутреннего профилировщика, встроенного в 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. Основная идея тут заключается в том, что, по умолчанию, мы полагаем, что экземпляры объектов не имеют интересных символов, а каждый раз, когда мы добавляем новое свойство к экземпляру, проверяем, является ли имя этого свойства подобным символом. Если это так, мы устанавливаем определённый бит в скрытых классах экземпляров объектов.
const obj = {};
Object.prototype.toString.call(obj); // быстрый путь выполнения вызова
obj[Symbol.toStringTag] = 'a';
Object.prototype.toString.call(obj); // медленный путь выполнения вызова
Взгляните на этот простой пример. Тут объект 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, нуждаются в оптимизации?
Автор: ru_vds