Оптимизация кода начинается не столько с изучения особенностей языка программирования, сколько с понимания схемы работы всей «технологической цепочки», задействованной при создании приложения — от алгоритма программы до компилятора.
Мы поговорили с Вячеславом Егоровым aka mraleph, инженером из Google, компиляторщиком до мозга костей, который работал над JavaScript движком под названием V8, встроенным в Chromium (и, как следствие в Chrome, Android версию браузера, облачную операционную систему Chrome OS) и в менее известный Maxthone.
JavaScript-программистам Вячеслав, скорее всего, известен как автор постов про внутренности V8 и как докладчик, увлеченно показывающий машинный код на конференциях для Web-разработчиков.
В настоящее время Вячеслав активно работает в Google над Dart VM.
В этом интервью он рассказал о том, что происходит внутри движка, выполняющего динамический JS-код и поделился примерами, как выполняются некоторые оптимизации и почему важно глубоко понимать работу движка, чтобы обеспечить быстрое выполнение кода.
— Расскажите, пожалуйста, в двух словах о себе и о работе в Google, связанной с JavaScript-движком V8?
— Закончил мехмат НГУ. Всегда интересовался компиляторами. Сначала работал в Новосибирской компании Excelsior, где люди делают свою собственную JVM с AOT-компилятором, потом ушел в Google. В Google сначала занимался V8, потом Dart VM, какое-то время даже починял различные баги в LuaJIT.
— Многие предпочитают машину V8 за её способность к оптимизации кода. В чём секрет?
— Основная сложность при оптимизации динамических языков программирования состоит в том, что для достижения пиковой производительности виртуальная машина должна, по сути дела, угадать намерение программиста и увидеть статическую структуру, скрытую в динамическом коде. Сильной чертой V8 является то, что она умеет достаточно хорошо эту самую структуру замечать.
К примеру, кто-то написал на JavaScript:
и оказывается, что V8 вполне способен скомпилировать тело этой функции в достаточно компактный машинный код:
Это совсем нетривиальная задача в условиях, когда все динамически типизировано и статически совершенно непонятно, что такое
или даже
— А что касается слабых сторон движка?
— Иногда сильная стороны V8 становится её слабой стороной. Происходит это по двум причинам.
Во-первых, не в любом динамически типизированном коде V8 способна увидеть статическую структуру, которая, возможно, и была очевидна написавшему этот код программисту. Где-то это происходит, потому что в V8 что-то ещё не реализовано, где-то — потому что алгоритмически не всегда возможно.
Один из часто встречающихся примеров — это код в стиле:
Здесь V8 не умеет замечать, что a.f и b.f имеют одно и тоже поведение (с поправкой на значение захваченных переменных), т.к. это функции, созданные из одного и того же функционального литерала (function literal).
Во-вторых, иногда оптимизирующий компилятор V8 просто не поддерживает какую-то конструкцию в коде, и поэтому компилятор отказывается смотреть на код. Например, Crankshaft (это первая реализация идеи адаптивной компиляции в V8) никогда не поддерживал try-catch/finally и поэтому отказывался оптимизировать функции, где он присутствовал. Сейчас функции, которые используют try-catch, компилируются через TurboFan (компилятор, разрабатываемый на замену Crankshaft), поэтому ситуация выправляется.
В-третьих, иногда издержки, затрачиваемые на выявление статической структуры, не окупаются. V8 тратит время, строит деревья скрытых классов, оптимизирует/ деоптимизирует/ оптимизирует снова код — а код лучше не становится, например, потому что объекты по большей части используются как словари. Это очень интересная проблемная область — как правильно балансировать издержки на оптимизацию кода и улучшение производительности от этой оптимизации. Как исполнять код с разумной скоростью, даже если этот код не попадает на очевидный fast path.
Работы над всеми этими проблемами ведутся, а потому в любых случаях, когда ваш код работает медленно на V8, следует жаловаться разработчикам V8 — хотя бы для того, чтобы они объяснили, почему код работает медленно или может быть даже починили что-то в самой V8.
Кстати, всё сказанное выше в той или иной степени относится к любому JavaScript движку, который хоть как-то старается оптимизировать ваш код. Все движки стараются угадать, что же программист хотел сказать своей программой, и стараются углядеть статическое в динамическом. У всех движков есть разделение на fast path и slow path и свои собственные особенности.
— Как вы считаете, имеет ли смысл оптимизация кода под определенный движок (в нашем случае — V8)? Ведь бывает так, что в каком-то браузере код тормозит. Что тогда делать?
— Здесь возможно два варианта (или их комбинация):
— Можете ли вы перечислить наиболее часто встречающиеся «грабли», на которые наступают разработчики в своем коде (т.е., по сути, наиболее часто встречающиеся ошибки в коде, сильно влияющие на производительность при ориентации на V8 — в рамках упомянутой выше ситуации «код плохой»)?
— Бывает два типа грабель. Первый тип — грабли алгоритмические. Например, иногда люди итерируют по строке, используя цикл в стиле:
Многие компиляторщики начинают слегка подпрыгивать на стуле и потирать руки, когда видят подобный код, поскольку реализовать оптимизацию, которая бы такой цикл превращала бы во что-то вменяемое — это жутко интересная задача. Однако с точки зрения разработчика гораздо эффективнее понимать, сколько стоит s.substring в лучшем и худшем случае, и такого кода не писать. Потому что без хитрых оптимизаций внутри движка этот цикл имеет временную сложность O(n2).
Другой тип грабель — это грабли, связанные с оптимизацией динамических языков (я уже это затронул выше). Например, полиморфный код, т.е. код, который работает с разными типами объектов. Такой код для V8 часто как криптонит (кристаллическое радиоактивное вещество, фигурирующее во вселенной DC Comics. Криптонит знаменит благодаря тому, что является единственной немагической слабостью Супермена и других криптонцев — он способен оказывать на них воздействие, которое разнится в зависимости от цвета минерала. — прим. ред.) для Супермена. Тема это достаточно глубокая, и у меня на эту тему есть целый пост с картинками.
— Как же бороться с этими проблемами? Искать «правила написания кода под определенный движок» и проверять свой код на соответствие им?
— Здесь самое главное — осознать, что проблемы с производительностью нельзя решить наскоком. Допустим, вы услышали про полиморфизм и побежали, теряя тапки, переписывать весь свой код в мономорфном стиле. Ничего хорошего из таких начинаний обычно не получается. Здесь нужен совершенно другой подход, который очень хорошо описывается классической русской поговоркой «семь раз отмерь, один раз отрежь». Нужно выстроить в своей голове ментальную модель того, как работает VM и того, как работает ваш код, нужно хорошенько попрофилировать, понять куда у вас утекает производительность, и уже потом задаваться вопросами оптимизации. Часто оказывается, что V8 делает свою работу хорошо, а настоящее бутылочное горлышко совсем даже не в javascript коде.
— А если вернуться к упомянутым вами проблемами кода компилятора (ситуации «код движка плохой»)? Можно ли привести какие-то наиболее часто отмечаемые проблемы V8?
— Баги обычно не проявляются «часто» или «не часто». Обычно бывает, что баг заметил какой-нибудь разработчик, его быстренько починили, и на этом все закончилось. Баги при этом задевают только малую популяцию разработчиков, потому что встречаются в совершенно особенных местах при правильном стечении обстоятельств.
Как пример из практики: меня однажды попросили посмотреть на код, который почему-то иногда начинал работать очень медленно. Оказалось все из-за выражения Math.floor(x * y), в котором иногда x становился равным -1, а y равным 0. Казалось бы ничего особенного, но Math.floor(x * y) в таком случае равняется волшебному числу «отрицательный ноль» (который почти совсем как 0, но если поделить, скажем, 1 на ноль, то получается положительная бесконечность, а если поделить 1 на минус ноль, то получается отрицательная бесконечность). Crankshaft-же всегда предполагал, что результат операции floor — это число целое, и потому -0 — число не представимое в виде целого, вызывало деоптимизацию. Решением проблемы в данном конкретном случае было заменить Math.floor(x * y) на (x * y) | 0 (вообще, это не эквивалентное преобразование, но для кода, который нужно было разогнать, не играло роли). Кстати, недавно эту проблему в Crankshaft починили раз и навсегда.
Я специально выбрал этот баг в качестве примера, т.к. он достаточно загадочный («что за минус-ноль?», «что за деоптимизация?») в надежде убедить читателя в том, что знание о конкретных багах совершенно бесполезно. Я обнаружил, что код, на который я смотрел, наступает на этот баг не потому, что я знал, что Math.floor не терпит -0, и вооруженный этим знанием пошел заменять все Math.floor на |0, пока баг сам не исправился… Нет, я нашел этот баг, поскольку знал, как профилировать V8, с какими ключами надо ее запускать, чтобы V8 мне показала список деоптимизаций. Поэтому важнее всего понимать, как V8 (и другие JS VM) работают.
Обладая этим знанием, можно всегда выяснить, «что же пошло не так где-то в глубоких подземельях», и потом пожаловаться в вышестоящие инстанции.
Я об этом достаточно много пишу в своем блоге и даже сделал тулзу, которая позволяет смотреть информацию, выдаваемую V8 (compiler IR, deopts, etc), в более-менее удобной форме.
— Вы же сейчас занимаетесь Dart? Когда же этот новый язык заменит JS? Нужно ли уже сейчас разработчику переходить на новый язык? Какое его положение в индустрии?
— Взять и вот просто так заменить JS во всей нашей вселенной нельзя. А вот на отдельных рабочих местах — вполне можно. Люди заменяют его разными вещами, кто-то ClojureScript, кто-то TypeScript, а кто-то Dart. Языки программирования — это штука сложная и конфликтная. Все имеют на их счет свое мнение. Поэтому мой совет обычно простой — устали от JavaScript? Можно сходить на dartlang.org, скачать SDK (или поиграться с языком прямо в браузере) и решить для самого себя.
Из интересных вещей, которые сейчас происходят с языком за пределами Web, можно выделить flutter.io — это фреймворк для разработки кроссплатформенных (Android & iOS) мобильных приложений и dartino — маленький Dart для встраиваемых систем.
Благодарим за беседу!
Примеры, приведенные выше, наглядно иллюстрируют, что одного лишь перечня «простых рекомендаций под конкретный движок» для оптимизации кода не достаточно — важно понимать принцип его работы, а заодно и четко представлять, каких результатов хочется добиться от программы.
5 июня в Питере Вячеслав выступит на JavaScript-конференции HolyJS c докладом про производительность JavaScript. Приходите!
Мы поговорили с Вячеславом Егоровым aka mraleph, инженером из Google, компиляторщиком до мозга костей, который работал над JavaScript движком под названием V8, встроенным в Chromium (и, как следствие в Chrome, Android версию браузера, облачную операционную систему Chrome OS) и в менее известный Maxthone.
JavaScript-программистам Вячеслав, скорее всего, известен как автор постов про внутренности V8 и как докладчик, увлеченно показывающий машинный код на конференциях для Web-разработчиков.
В настоящее время Вячеслав активно работает в Google над Dart VM.
В этом интервью он рассказал о том, что происходит внутри движка, выполняющего динамический JS-код и поделился примерами, как выполняются некоторые оптимизации и почему важно глубоко понимать работу движка, чтобы обеспечить быстрое выполнение кода.
Движок V8 был разработан датским подразделением компании Google и распространяется по лицензии BSD. Движок написан на C++ и поддерживает спецификацию ECMA-262.
Первая версия движка появилась в 2008 году. На текущий момент активная разработка движка продолжается по большей части в мюнхенском офисе Google.
Среди основных особенностей движка — компиляция javascript непосредственно в машинный код, адаптивная оптимизация и деоптимизация кода во время компиляции, быстрый сбор мусора.
— Расскажите, пожалуйста, в двух словах о себе и о работе в Google, связанной с JavaScript-движком V8?
— Закончил мехмат НГУ. Всегда интересовался компиляторами. Сначала работал в Новосибирской компании Excelsior, где люди делают свою собственную JVM с AOT-компилятором, потом ушел в Google. В Google сначала занимался V8, потом Dart VM, какое-то время даже починял различные баги в LuaJIT.
— Многие предпочитают машину V8 за её способность к оптимизации кода. В чём секрет?
— Основная сложность при оптимизации динамических языков программирования состоит в том, что для достижения пиковой производительности виртуальная машина должна, по сути дела, угадать намерение программиста и увидеть статическую структуру, скрытую в динамическом коде. Сильной чертой V8 является то, что она умеет достаточно хорошо эту самую структуру замечать.
К примеру, кто-то написал на JavaScript:
function len(p) {
return Math.sqrt(p.x * p.x + p.y * p.y);
}
и оказывается, что V8 вполне способен скомпилировать тело этой функции в достаточно компактный машинный код:
vmovsd xmm1,[rax+0x17]
vmulsd xmm1,xmm1,xmm1
vmovsd xmm2,[rax+0x1f]
vmulsd xmm2,xmm2,xmm2
vaddsd xmm1,xmm2,xmm1
vsqrtsd xmm1,xmm1,xmm1
Это совсем нетривиальная задача в условиях, когда все динамически типизировано и статически совершенно непонятно, что такое
p.x
или даже
Math.sqrt
.— А что касается слабых сторон движка?
— Иногда сильная стороны V8 становится её слабой стороной. Происходит это по двум причинам.
Во-первых, не в любом динамически типизированном коде V8 способна увидеть статическую структуру, которая, возможно, и была очевидна написавшему этот код программисту. Где-то это происходит, потому что в V8 что-то ещё не реализовано, где-то — потому что алгоритмически не всегда возможно.
Один из часто встречающихся примеров — это код в стиле:
function make(x) {
return { f: function () { return x } }
}
var a = make(0), b = make(0);
Здесь V8 не умеет замечать, что a.f и b.f имеют одно и тоже поведение (с поправкой на значение захваченных переменных), т.к. это функции, созданные из одного и того же функционального литерала (function literal).
Во-вторых, иногда оптимизирующий компилятор V8 просто не поддерживает какую-то конструкцию в коде, и поэтому компилятор отказывается смотреть на код. Например, Crankshaft (это первая реализация идеи адаптивной компиляции в V8) никогда не поддерживал try-catch/finally и поэтому отказывался оптимизировать функции, где он присутствовал. Сейчас функции, которые используют try-catch, компилируются через TurboFan (компилятор, разрабатываемый на замену Crankshaft), поэтому ситуация выправляется.
В-третьих, иногда издержки, затрачиваемые на выявление статической структуры, не окупаются. V8 тратит время, строит деревья скрытых классов, оптимизирует/ деоптимизирует/ оптимизирует снова код — а код лучше не становится, например, потому что объекты по большей части используются как словари. Это очень интересная проблемная область — как правильно балансировать издержки на оптимизацию кода и улучшение производительности от этой оптимизации. Как исполнять код с разумной скоростью, даже если этот код не попадает на очевидный fast path.
Работы над всеми этими проблемами ведутся, а потому в любых случаях, когда ваш код работает медленно на V8, следует жаловаться разработчикам V8 — хотя бы для того, чтобы они объяснили, почему код работает медленно или может быть даже починили что-то в самой V8.
Кстати, всё сказанное выше в той или иной степени относится к любому JavaScript движку, который хоть как-то старается оптимизировать ваш код. Все движки стараются угадать, что же программист хотел сказать своей программой, и стараются углядеть статическое в динамическом. У всех движков есть разделение на fast path и slow path и свои собственные особенности.
— Как вы считаете, имеет ли смысл оптимизация кода под определенный движок (в нашем случае — V8)? Ведь бывает так, что в каком-то браузере код тормозит. Что тогда делать?
— Здесь возможно два варианта (или их комбинация):
- ваш код «плохой». В этом случае обычно страдает производительность сразу под несколькими движками, и оптимизация под один движок улучшает производительность кода сразу под несколькими;
- код движка «плохой». В этом случае, как я отметил ранее, надо отсылать баг репорт разработчикам движка, которые могут либо починить сам движок, либо часто рекомендовать способ обойти этот баг.
— Можете ли вы перечислить наиболее часто встречающиеся «грабли», на которые наступают разработчики в своем коде (т.е., по сути, наиболее часто встречающиеся ошибки в коде, сильно влияющие на производительность при ориентации на V8 — в рамках упомянутой выше ситуации «код плохой»)?
— Бывает два типа грабель. Первый тип — грабли алгоритмические. Например, иногда люди итерируют по строке, используя цикл в стиле:
for (; s != ""; s = s.substring(1))
Многие компиляторщики начинают слегка подпрыгивать на стуле и потирать руки, когда видят подобный код, поскольку реализовать оптимизацию, которая бы такой цикл превращала бы во что-то вменяемое — это жутко интересная задача. Однако с точки зрения разработчика гораздо эффективнее понимать, сколько стоит s.substring в лучшем и худшем случае, и такого кода не писать. Потому что без хитрых оптимизаций внутри движка этот цикл имеет временную сложность O(n2).
Другой тип грабель — это грабли, связанные с оптимизацией динамических языков (я уже это затронул выше). Например, полиморфный код, т.е. код, который работает с разными типами объектов. Такой код для V8 часто как криптонит (кристаллическое радиоактивное вещество, фигурирующее во вселенной DC Comics. Криптонит знаменит благодаря тому, что является единственной немагической слабостью Супермена и других криптонцев — он способен оказывать на них воздействие, которое разнится в зависимости от цвета минерала. — прим. ред.) для Супермена. Тема это достаточно глубокая, и у меня на эту тему есть целый пост с картинками.
— Как же бороться с этими проблемами? Искать «правила написания кода под определенный движок» и проверять свой код на соответствие им?
— Здесь самое главное — осознать, что проблемы с производительностью нельзя решить наскоком. Допустим, вы услышали про полиморфизм и побежали, теряя тапки, переписывать весь свой код в мономорфном стиле. Ничего хорошего из таких начинаний обычно не получается. Здесь нужен совершенно другой подход, который очень хорошо описывается классической русской поговоркой «семь раз отмерь, один раз отрежь». Нужно выстроить в своей голове ментальную модель того, как работает VM и того, как работает ваш код, нужно хорошенько попрофилировать, понять куда у вас утекает производительность, и уже потом задаваться вопросами оптимизации. Часто оказывается, что V8 делает свою работу хорошо, а настоящее бутылочное горлышко совсем даже не в javascript коде.
— А если вернуться к упомянутым вами проблемами кода компилятора (ситуации «код движка плохой»)? Можно ли привести какие-то наиболее часто отмечаемые проблемы V8?
— Баги обычно не проявляются «часто» или «не часто». Обычно бывает, что баг заметил какой-нибудь разработчик, его быстренько починили, и на этом все закончилось. Баги при этом задевают только малую популяцию разработчиков, потому что встречаются в совершенно особенных местах при правильном стечении обстоятельств.
Как пример из практики: меня однажды попросили посмотреть на код, который почему-то иногда начинал работать очень медленно. Оказалось все из-за выражения Math.floor(x * y), в котором иногда x становился равным -1, а y равным 0. Казалось бы ничего особенного, но Math.floor(x * y) в таком случае равняется волшебному числу «отрицательный ноль» (который почти совсем как 0, но если поделить, скажем, 1 на ноль, то получается положительная бесконечность, а если поделить 1 на минус ноль, то получается отрицательная бесконечность). Crankshaft-же всегда предполагал, что результат операции floor — это число целое, и потому -0 — число не представимое в виде целого, вызывало деоптимизацию. Решением проблемы в данном конкретном случае было заменить Math.floor(x * y) на (x * y) | 0 (вообще, это не эквивалентное преобразование, но для кода, который нужно было разогнать, не играло роли). Кстати, недавно эту проблему в Crankshaft починили раз и навсегда.
Я специально выбрал этот баг в качестве примера, т.к. он достаточно загадочный («что за минус-ноль?», «что за деоптимизация?») в надежде убедить читателя в том, что знание о конкретных багах совершенно бесполезно. Я обнаружил, что код, на который я смотрел, наступает на этот баг не потому, что я знал, что Math.floor не терпит -0, и вооруженный этим знанием пошел заменять все Math.floor на |0, пока баг сам не исправился… Нет, я нашел этот баг, поскольку знал, как профилировать V8, с какими ключами надо ее запускать, чтобы V8 мне показала список деоптимизаций. Поэтому важнее всего понимать, как V8 (и другие JS VM) работают.
Обладая этим знанием, можно всегда выяснить, «что же пошло не так где-то в глубоких подземельях», и потом пожаловаться в вышестоящие инстанции.
Я об этом достаточно много пишу в своем блоге и даже сделал тулзу, которая позволяет смотреть информацию, выдаваемую V8 (compiler IR, deopts, etc), в более-менее удобной форме.
— Вы же сейчас занимаетесь Dart? Когда же этот новый язык заменит JS? Нужно ли уже сейчас разработчику переходить на новый язык? Какое его положение в индустрии?
— Взять и вот просто так заменить JS во всей нашей вселенной нельзя. А вот на отдельных рабочих местах — вполне можно. Люди заменяют его разными вещами, кто-то ClojureScript, кто-то TypeScript, а кто-то Dart. Языки программирования — это штука сложная и конфликтная. Все имеют на их счет свое мнение. Поэтому мой совет обычно простой — устали от JavaScript? Можно сходить на dartlang.org, скачать SDK (или поиграться с языком прямо в браузере) и решить для самого себя.
Из интересных вещей, которые сейчас происходят с языком за пределами Web, можно выделить flutter.io — это фреймворк для разработки кроссплатформенных (Android & iOS) мобильных приложений и dartino — маленький Dart для встраиваемых систем.
Благодарим за беседу!
Примеры, приведенные выше, наглядно иллюстрируют, что одного лишь перечня «простых рекомендаций под конкретный движок» для оптимизации кода не достаточно — важно понимать принцип его работы, а заодно и четко представлять, каких результатов хочется добиться от программы.
5 июня в Питере Вячеслав выступит на JavaScript-конференции HolyJS c докладом про производительность JavaScript. Приходите!