Используйте криминалистику и детективную работу, чтобы разгадать тайны производительности JavaScript

Введение

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

Семинар Google I/O 2013

Этот материал я представлял на конференции Google I/O 2013. Посмотрите видео ниже.

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

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

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

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

V8 CSI: Oz

Разработчики проекта Find Your Way to Oz обратились к команде V8 с проблемой производительности, которую они не могли решить самостоятельно. Иногда Oz зависал, вызывая задержки. Разработчики Oz провели предварительное исследование с помощью панели Timeline в инструментах разработчика Chrome. Проанализировав использование памяти, они обнаружили ужасный пилообразный график. Раз в секунду сборщик мусора собирал 10 МБ мусора, и паузы в сборке мусора соответствовали задержкам. как показано на скриншоте ниже из временной шкалы в инструментах разработчика Chrome:

Хронология инструментов разработчика

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

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

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

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

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

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

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

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

Следуют ли разработчики рекомендациям?

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

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

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

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

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

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

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

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

Ожидается ли, что в этом приложении будет 10 МБ/с мусора?

Короткий ответ: нет. Разработчик не делает ничего, что могло бы привести к генерации 10 МБ мусора в секунду.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Подозреваемый № 4 – возможный вариант.

Судебные экспертизы

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

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

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

Если Chrome не запущен, откройте его с помощью следующих флагов:

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

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

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

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

$ 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 – plot-timer-event. Его можно выполнить следующим образом:

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

После выполнения в текущем каталоге появится файл timer-events.png. Открыв его, вы увидите примерно следующее:

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

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

Ось Y для событий таймера

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

Одна из самых важных строк – "code kind being executed" (тип выполняемого кода). Он будет зеленым, когда выполняется оптимизированный код, и красно-синим, когда выполняется неоптимизированный код. На скриншоте ниже показан переход от оптимизированного кода к неоптимизированному и обратно:

Тип выполняемого кода

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

Если вы зашли так далеко, стоит отметить, что вы можете работать намного быстрее, если проведете рефакторинг своего приложения, чтобы оно могло работать в отладочной оболочке v8: d8. Использование d8 позволяет сократить время итерации с помощью инструментов tick-processor и plot-timer-event. Ещё один побочный эффект использования 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:

[disabled optimization for updateSprites, reason: ForInStatement is not fast case]

Как и предполагали детективы, причиной была конструкция цикла 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. Вы можете легко убедиться в этом, проведя те же эксперименты с новым кодом. Внимательный читатель заметит, что числа с двойной точностью по-прежнему хранятся как свойства. Если профилирование показывает, что это целесообразно, то изменение позиции на массив чисел с плавающей запятой двойной точности или массив типизированных данных позволит ещё больше сократить количество создаваемых объектов.

Эпилог

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

Пора раскрыть преступления против производительности!