Используйте судебно-медицинскую экспертизу и детективную деятельность для решения загадок производительности JavaScript.

Введение

В последние годы веб-приложения значительно ускорились. Многие приложения теперь работают достаточно быстро, и я слышал, как некоторые разработчики вслух задаются вопросом: «Достаточно ли быстр Интернет?». Для некоторых приложений это может быть так, но мы знаем, что для разработчиков, работающих над высокопроизводительными приложениями, это недостаточно быстро. Несмотря на удивительные достижения в технологии виртуальных машин JavaScript, недавнее исследование показало, что приложения Google проводят от 50% до 70% своего времени внутри V8 . Ваше приложение имеет ограниченное количество времени, сокращение циклов в одной системе означает, что другая система может делать больше. Помните, что приложения, работающие со скоростью 60 кадров в секунду, имеют только 16 мс на кадр, иначе — зависания . Продолжайте читать, чтобы узнать об оптимизации JavaScript и профилировании приложений JavaScript, из окопной истории детективов по производительности из команды V8, отслеживающих неясную проблему с производительностью в Find Your Way to Oz .

Сессия Google I/O 2013

Я представил этот материал на Google I/O 2013. Посмотрите видео ниже:

Почему производительность имеет значение?

Циклы ЦП — это игра с нулевой суммой. Уменьшив использование одной части вашей системы, вы сможете использовать больше в другой или работать более плавно в целом. Бежать быстрее и делать больше часто являются конкурирующими целями. Пользователи требуют новых функций, одновременно ожидая, что ваше приложение будет работать более плавно. Виртуальные машины JavaScript продолжают становиться быстрее, но это не повод игнорировать проблемы с производительностью, которые можно исправить уже сегодня, как уже знают многие разработчики, сталкивающиеся с проблемами производительности в своих веб-приложениях. В приложениях, работающих в режиме реального времени, с высокой частотой кадров, необходимость отсутствия зависаний имеет первостепенное значение. Insomniac Games провела исследование , которое показало, что стабильная и устойчивая частота кадров важна для успеха игры: «Высокая частота кадров по-прежнему является признаком профессионального, хорошо сделанного продукта». Веб-разработчики принимают это к сведению.

Решение проблем с производительностью

Решение проблемы производительности похоже на раскрытие преступления. Вам необходимо тщательно изучить доказательства, проверить предполагаемые причины и поэкспериментировать с различными решениями. На протяжении всего пути вы должны документировать свои измерения, чтобы быть уверенными, что вы действительно устранили проблему. Между этим методом и тем, как детективы-криминалисты раскрывают дело, очень мало различий. Детективы исследуют улики, допрашивают подозреваемых и проводят эксперименты в надежде найти дымящийся пистолет.

V8 CSI: Оз

Удивительные волшебники, создающие Find Your Way to Oz, обратились к команде V8 с проблемой производительности, которую они не могли решить самостоятельно. Время от времени Оз замерзал, вызывая рывки. Разработчики из страны Оз провели первоначальное расследование, используя панель временной шкалы в Chrome DevTools . Глядя на использование памяти, они столкнулись с ужасным пилообразным графиком. Раз в секунду сборщик мусора собирал 10 МБ мусора, и паузы в сборе мусора соответствовали джанку. Аналогично следующему снимку экрана с временной шкалой в Chrome Devtools:

График работы инструментов разработчика

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

Доказательство

Первым шагом является сбор и изучение первоначальных доказательств.

Какой тип приложения мы рассматриваем?

Демоверсия Oz — это интерактивное 3D-приложение. Из-за этого он очень чувствителен к паузам, вызванным сборкой мусора. Помните, что интерактивному приложению, работающему со скоростью 60 кадров в секунду, требуется 16 мс на выполнение всей работы JavaScript, и часть этого времени необходимо оставить Chrome для обработки графических вызовов и рисования экрана .

Оз выполняет множество арифметических вычислений над двойными значениями и часто вызывает WebAudio и WebGL.

Какую проблему с производительностью мы наблюдаем?

Мы наблюдаем паузы, иначе говоря, пропуски кадров или зависания. Эти паузы коррелируют с запуском сборки мусора.

Следуют ли разработчики передовому опыту?

Да, разработчики из Oz хорошо разбираются в методах производительности и оптимизации виртуальных машин JavaScript. Стоит отметить, что разработчики Oz использовали CoffeeScript в качестве исходного языка и создавали код JavaScript с помощью компилятора CoffeeScript. Это усложнило расследование из-за разрыва между кодом, написанным разработчиками Oz, и кодом, используемым V8. Chrome DevTools теперь поддерживает карты исходного кода , что облегчило бы задачу.

Почему работает сборщик мусора?

Память в JavaScript автоматически управляется виртуальной машиной для разработчика. V8 использует общую систему сборки мусора, в которой память делится на два (или более) поколения . Молодое поколение держит в руках объекты, которые недавно были выделены. Если объект существует достаточно долго, он перемещается в старое поколение.

Молодое поколение собирается гораздо чаще, чем старое поколение. Это сделано специально, ведь коллекции для молодого поколения обходятся гораздо дешевле. Часто можно с уверенностью предположить, что частые паузы GC вызваны сбором данных молодого поколения.

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

V8 молодая память

На этом этапе точки из космоса и в космос меняются местами. То, что было в пространстве, а теперь является объектом из космоса, сканируется от начала до конца, и все еще живые объекты копируются в пространство или перемещаются в кучу старого поколения. Если вам нужны подробности, предлагаю вам прочитать об алгоритме Чейни .

Интуитивно вы должны понимать, что каждый раз, когда объект выделяется явно или неявно (через вызов new, [] или {}), ваше приложение все ближе и ближе приближается к сборке мусора и ужасной паузе приложения.

Ожидается ли для этого приложения 10 МБ/сек мусора?

Короче говоря, нет. Разработчик не делает ничего, чтобы ожидать 10 МБ/сек мусора.

Подозреваемые

Следующим этапом расследования является выявление потенциальных подозреваемых, а затем их уничтожение.

Подозреваемый №1

Вызов нового во время кадра. Помните, что каждый выделенный объект приближает вас к паузе GC. Приложения, работающие с высокой частотой кадров, в частности, должны стремиться к нулевому выделению ресурсов на кадр. Обычно для этого требуется тщательно продуманная система переработки объектов, специфичная для конкретного приложения. Детективы V8 сверились с командой Оз, и они не звонили новым. На самом деле команда из страны Оз уже хорошо знала об этом требовании и сказала: «Это было бы неловко». Вычеркните это из списка.

Подозреваемый №2

Изменение «формы» объекта вне конструктора. Это происходит всякий раз, когда к объекту добавляется новое свойство вне конструктора. Это создает новый скрытый класс для объекта. Когда оптимизированный код увидит этот новый скрытый класс, будет активирована деоптимизация, неоптимизированный код будет выполняться до тех пор, пока код не будет классифицирован как горячий и снова оптимизирован. Эта деоптимизация и повторная оптимизация приведут к зависанию, но не коррелируют строго с чрезмерным созданием мусора. После тщательного аудита кода было подтверждено, что формы объектов статичны, поэтому подозрение №2 было исключено.

Подозреваемый №3

Арифметика в неоптимизированном коде. В неоптимизированном коде все вычисления приводят к выделению реальных объектов. Например, этот фрагмент:

var a = p * d;
var b = c + 3;
var c = 3.3 * dt;
point.x = a * b * c;

В результате создается 5 объектов HeapNumber. Первые три предназначены для переменных a, b и c. Четвертый — для анонимного значения (a * b), а пятый — из #4 * c; Пятый в конечном итоге назначается точке.x.

Оз выполняет тысячи таких операций за кадр. Если какие-либо из этих вычислений происходят в функциях, которые никогда не оптимизируются, они могут быть причиной мусора. Потому что вычисления в неоптимизированном режиме выделяют память даже для временных результатов.

Подозреваемый №4

Сохранение числа двойной точности в свойстве. Необходимо создать объект HeapNumber для хранения числа и свойства, измененного так, чтобы оно указывало на этот новый объект. Изменение свойства так, чтобы оно указывало на HeapNumber, не приведет к появлению мусора. Однако возможно, что в качестве свойств объекта хранится множество чисел двойной точности. Код полон операторов, подобных следующим:

sprite.position.x += 0.5 * (dt);

В оптимизированном коде каждый раз, когда x присваивается свежевычисленное значение (это, казалось бы, безобидный оператор), неявно выделяется новый объект HeapNumber, что приближает нас к паузе в сборе мусора.

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

Подозреваемый №4 вполне возможен.

Криминалистика

На данный момент у детективов есть два возможных подозреваемых: хранение чисел в куче как свойств объекта и арифметические вычисления, происходящие внутри неоптимизированных функций. Пришло время отправиться в лабораторию и окончательно определить, кто из подозреваемых виновен. ПРИМЕЧАНИЕ. В этом разделе я буду использовать воспроизведение проблемы, обнаруженной в исходном коде Oz. Это воспроизведение на несколько порядков меньше исходного кода, поэтому его легче рассуждать.

Эксперимент №1

Проверка подозреваемого №3 (арифметические вычисления внутри неоптимизированных функций). Движок JavaScript V8 имеет встроенную систему журналирования, которая позволяет получить отличное представление о том, что происходит внутри.

Начиная с того, что Chrome вообще не работает, запускаем Chrome с флагами:

--no-sandbox --js-flags="--prof --noprof-lazy --log-timer-events"

а затем полный выход из Chrome приведет к созданию файла v8.log в текущем каталоге.

Чтобы интерпретировать содержимое v8.log, вам необходимо загрузить ту же версию v8, которую использует ваш Chrome (проверьте about:version), и собрать ее .

После успешной сборки v8 можно обработать лог с помощью тикового процессора:

$ tools/linux-tick-processor /path/to/v8.log

(Замените Linux на Mac или Windows в зависимости от вашей платформы.) (Этот инструмент необходимо запускать из исходного каталога верхнего уровня в v8.)

Тиковый процессор отображает текстовую таблицу функций JavaScript, у которых было наибольшее количество тиков:

[JavaScript]:
ticks  total  nonlib   name
167   61.2%   61.2%  LazyCompile: *opt demo.js:12
 40   14.7%   14.7%  LazyCompile: unopt demo.js:20
 15    5.5%    5.5%  Stub: KeyedLoadElementStub
 13    4.8%    4.8%  Stub: BinaryOpStub_MUL_Alloc_Number+Smi
  6    2.2%    2.2%  Stub: BinaryOpStub_ADD_OverwriteRight_Number+Number
  4    1.5%    1.5%  Stub: KeyedStoreElementStub
  4    1.5%    1.5%  KeyedLoadIC:  {12}
  2    0.7%    0.7%  KeyedStoreIC:  {13}
  1    0.4%    0.4%  LazyCompile: ~main demo.js:30

Вы можете видеть, что в demo.js есть три функции: opt, unopt и main. Оптимизированные функции отмечены звездочкой (*) рядом с их именами. Обратите внимание, что функция opt оптимизирована, а unopt неоптимизирована.

Еще одним важным инструментом в наборе инструментов детектива V8 является таймер событий. Это можно выполнить так:

$ tools/plot-timer-event /path/to/v8.log

После запуска файл PNG с именем timer-events.png окажется в текущем каталоге. Открыв его, вы должны увидеть что-то похожее на это:

События таймера

Помимо графика внизу, данные отображаются в строках. Ось X — время (мс). В левой части находятся метки для каждой строки:

События таймера, ось Y

В строке V8.Execute отображается черная вертикальная линия на каждом тике профиля, где V8 выполнял код JavaScript. На V8.GCScavenger нарисована синяя вертикальная линия на каждом тике профиля, где V8 выполнял сбор нового поколения. Аналогично и для остальных состояний V8.

Одной из наиболее важных строк является «вид выполняемого кода». Он будет зеленым, когда выполняется оптимизированный код, и сочетанием красного и синего, когда выполняется неоптимизированный код. На следующем снимке экрана показан переход от оптимизированного к неоптимизированному, а затем обратно к оптимизированному коду:

Тип кода, который выполняется

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

Если вы зашли так далеко, стоит отметить, что вы можете работать намного быстрее, реорганизовав свое приложение так, чтобы оно могло работать в оболочке отладки v8: d8. Использование d8 сокращает время итерации с помощью инструментов тикового процессора и событий таймера графика. Еще одним побочным эффектом использования d8 является то, что становится легче изолировать реальную проблему, уменьшая количество шума, присутствующего в данных.

Глядя на график событий таймера из исходного кода Oz, можно увидеть переход от оптимизированного к неоптимизированному коду, и при выполнении неоптимизированного кода было запущено множество коллекций нового поколения, как показано на следующем снимке экрана (время примечания было удалено в середине):

График событий таймера

Если вы присмотритесь, то увидите, что черные линии, указывающие, когда V8 выполняет код JavaScript, отсутствуют точно в то же время такта профиля, что и коллекции нового поколения (синие линии). Это наглядно демонстрирует, что во время сбора мусора выполнение скрипта приостанавливается.

Глядя на выходные данные процессора тиков из исходного кода Oz, можно увидеть, что верхняя функция (updateSprites) не была оптимизирована. Другими словами, функция, в которой программа проводила больше всего времени, также оказалась неоптимизированной. Это убедительно указывает на то, что виновником является подозреваемый №3. Исходный код updateSprites содержал циклы, которые выглядели следующим образом:

function updateSprites(dt) {
    for (var sprite in sprites) {
        sprite.position.x += 0.5 * dt;
        // 20 more lines of arithmetic computation.
    }
}

Зная V8 так хорошо, как они, они сразу поняли, что конструкция цикла for-i-in иногда не оптимизируется V8. Другими словами, если функция содержит конструкцию цикла for-i-in, ее нельзя оптимизировать. Сегодня это особый случай, и он, вероятно, изменится в будущем, то есть однажды V8 может оптимизировать эту конструкцию цикла. Поскольку мы не детективы V8 и не знаем V8 как свои пять пальцев, как мы можем определить, почему updateSprites не был оптимизирован?

Эксперимент №2

Запуск Chrome с этим флагом:

--js-flags="--trace-deopt --trace-opt-verbose"

отображает подробный журнал данных оптимизации и деоптимизации. Просматривая данные updateSprites, мы находим:

[отключена оптимизация для updateSprites, причина: ForInStatement не является быстрым случаем]

Как и предполагали детективы, причиной была конструкция цикла for-i-in.

Дело закрыто

После обнаружения причины, по которой updateSprites не был оптимизирован, исправить это было просто: просто переместите вычисления в отдельную функцию, а именно:

function updateSprite(sprite, dt) {
    sprite.position.x += 0.5 * dt;
    // 20 more lines of arithmetic computation.
}

function updateSprites(dt) {
    for (var sprite in sprites) {
        updateSprite(sprite, dt);
    }
}

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

Эпилог

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

Выходите и начните раскрывать некоторые преступления, связанные с производительностью!