Теория и практика Java: Динамическая компиляция и измерение производительности

Проведение и интерпретация испытаний производительности для динамически компилируемых языков программирования, таких как Java, является намного более трудной задачей, чем для статически компилируемых языков, например C или C++. В данной статье серии Теория и практика Java Brian Goetz объясняет несколько из множества причин, по которым динамическая компиляция может усложнить тест производительности.

http://www-128.ibm.com/developerworks/java/library/j-jtp12214/

Bertrand Portier (brian@quiotix.com)
Ведущий консультант, Quiotix
21 Dec, 2004

Содержание

Динамическая компиляция - краткая история
Так какое отношение все это имеет к тесту производительности?
Удаление "мертвого" кода
Тренировка
Динамическая деоптимизация
Заключение
Ресурсы
Об авторах

Динамическая компиляция - краткая история

Процесс компиляции Java-приложения отличается от процесса компиляции статически компилируемых языков программирования, подобных С или С++. Статический компилятор преобразует исходный код непосредственно в машинные инструкции, которые могут быть выполнены на целевой платформе. Различные аппаратные платформы требуют применения различных компиляторов. Java-компилятор преобразует исходный Java-код в переносимые байткоды JVM, которые являются для JVM "инструкциями виртуальной машины". В отличие от статических компиляторов javac выполняет очень маленькую оптимизацию - оптимизация, проводимая статическими компиляторами, выполняется во время исполнения программы.

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

Just-in-time (оперативная) компиляция

Интерпретация была хороша для proof-of-concept (доказательство идеи) реализации, но первые JVM сразу обвинили в медлительности. Следующие поколения JVM использовали just-in-time (JIT) компиляторы для ускорения работы. Строго определенная JIT-виртуальная машина перед выполнением преобразовывает все байткоды в машинный код, но делает это в неторопливом стиле: JIT компилирует code path только тогда, когда знает, что code path будет сейчас выполняться (отсюда и название just-in-time компиляция). Этот подход дает возможность программам стартовать быстрее, поскольку не требуется длительная фаза компиляции перед возможным началом исполнения кода.

Использование JIT выглядело многообещающим, но существуют недостатки. JIT-компиляция устранила накладные расходы интерпретации (за счет некоторого дополнительного замедления запуска), но уровень оптимизации кода по некоторым причинам был посредственным. Во избежание значительного замедления при запуске Java-приложений JIT-компилятор должен быть быстрым, т.е. он не может тратить много времени на оптимизацию. И первые JIT-компиляторы были консервативны в создании встроенных предположений, поскольку они не знали, какие классы могут быть загружены позднее.

С технической точки зрения JIT-виртуальная машина компилирует каждый байткод перед его выполнением. Термин JIT часто используется для обозначения любой динамической компиляции байткода в машинный код, даже когда возможна интерпретация байткода.

Динамическая компиляция HotSpot

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

HotSpot поставляется с двумя компиляторами: клиентским и серверным. По умолчанию используется клиентский компилятор. Вы можете выбрать серверный компилятор, указав параметр -server при запуске JVM. Серверный компилятор оптимизирован для повышения пиковой скорости работы и предназначен для "долгоиграющих" серверных приложений. Клиентский компилятор оптимизирован для уменьшения времени начального запуска приложения и занимаемого объема памяти, реализует менее сложную оптимизацию, чем серверный компилятор, и, следовательно, требует меньше времени для компиляции.

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

Непрерывная перекомпиляция

Еще одна интересная особенность HotSpot - компиляция не осуществляется по принципу "все или ничего". Code path компилируется в машинный код после интерпретации его определенное количество раз. Но JVM продолжает анализ и может перекомпилировать код заново с более высоким уровнем оптимизации, если решит, что code path является особенно "горячим" или последующий анализ данных показал возможность дополнительной оптимизации. JVM может перекомпилировать одни и те же байткоды много раз при выполнении одиночного приложения. Для получения более подробной информации о работе компилятора попробуйте вызвать JVM с флагом -XX:+PrintCompilation, который заставляет компилятор (клиентский или серверный) каждый раз во время своей работы выводить на экран короткое сообщение.

Замещение в стеке

Первая версия HotSpot выполняла компиляцию по одному методу в каждый момент времени. Метод считался "горячим", если он выполнял итерации цикла, превышающие определенное количество раз (10000 в первой версии HotSpot). Это определялось назначением каждому методу счетчика и увеличением его значения каждый раз при выполнении обратного перехода. Однако после компиляции метода переключение на компилированную версию не производилось до тех пор, пока не происходил выход из метода и повторный вход - компилированная версия могла использоваться только для последовательных вызовов. В результате этого в некоторых ситуациях компилированная версия вообще никогда не использовалась, например, в случае программы, выполняющей интенсивные вычисления и в которой все вычисления производятся одинарным вызовом метода. В такой ситуации вычисляющий метод может быть скомпилирован, но компилированный код ни разу не использоваться.

Более свежие версии HotSpot используют технологию, называемую on-stack replacement (OSR), для разрешения переключения от интерпретируемого кода к компилированному (или замены одной версии компилированного кода другой) в середине цикла.

Так какое отношение все это имеет к тесту производительности?

Я обещал вам статью о тестах производительности и измерении производительности, но пока вы только получили урок по истории и пересказ белых книг HotSpot от Sun. Причиной такого длительного отступления является то, что без понимания процесса динамической компиляции практически невозможно правильно писать или интерпретировать тесты производительности для Java-классов. (Это сделать довольно непросто даже при глубоком понимании динамической компиляции и JVM-оптимизации.)

Писать микротесты производительности для Java-программы намного сложнее, чем для C-программы.

Традиционным способом определения того, что Вариант А быстрее Варианта Б, является написание маленького теста производительности, часто называемого микротестом производительности. Это очень благоразумно. Научный метод требует независимого исследования! Увы. Написание (и интерпретация) тестов производительности является намного более трудной и запутанной задачей для языков с динамической компиляцией, чем для языков со статической компиляцией. Ничего нет плохого в попытке узнать что-либо о производительности определенной конструкции путем написания программы, ее использующей. Но в большинстве случаев написанные на Java микротесты не покажут вам того, что вы думали получить.

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

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

С другой стороны, HotSpot JIT периодически перекомпилирует Java-байткод в машинный код при выполнении вашей программы, и эта перекомпиляция может выполняться в неожиданное время после накопления определенного количества данных для анализа, при загрузке новых классов или при выполнении code path, еще не переведенных в разряд загруженных классов. Измерения времени при постоянной перекомпиляции могут быть совершенно не точными и обманчивыми, и очень часто нужно выполнять Java-код довольно продолжительное время (я видел анекдотические ситуации повышения производительности после часов или даже дней, прошедших с момента запуска программы) до получения полезных данных о производительности.

Удаление "мертвого" кода

Одной из проблем написания хорошей тестовой программы является удаление оптимизирующим компилятором "мертвого" кода - кода, не влияющего на результат выполнения программы. Программы тестов производительности часто не выполняют вывод каких-либо результатов на экран; это означает, что некоторый или весь ваш код может быть оптимизирован и удален без вашего ведома, и с этого момента вы будете тестировать не то что думаете. В частности, многие микротесты работают намного лучше, будучи запущенными с параметром -server вместо -client, не потому что серверный компилятор быстрее (хотя чаще всего так и есть), а потому что серверный компилятор более приспособлен к оптимизации фрагментов "мертвого" кода. К сожалению, такая оптимизация "мертвого" кода, ускоряющая работу вашей тестовой программы (возможно оптимизируя ее полностью), не работает также хорошо с программой, которая что-то реально делает.

Странный результат

Листинг 1 содержит пример кода теста производительности с ничего не делающим блоком, который был взят из теста для измерения производительности параллельных потоков, но вместо этого измеряет что-то совершенно другое. (Этот пример был позаимствован из отличной презентации JavaOne 2003 "The Black Art of Benchmarking". См. раздел Ресурсы.)

Листинг 1. Тест производительности, искаженный непреднамеренным "мертвым" кодом

public class StupidThreadTest {
 
public static void doSomeStuff() {
   
double uselessSum = 0;
   
for (int i = 0; i < 1000; i++) {
     
for (int j = 0; j < 1000; j++) {
       
uselessSum += (double) i + (double) j;
     
}
    }
  }

 
public static void main(String[] args) throws InterruptedException {
   
doSomeStuff();

   
int nThreads = Integer.parseInt(args[0]);
    Thread
[] threads = new Thread[nThreads];
   
for (int i = 0; i < nThreads; i++)
     
threads[i] = new Thread(new Runnable() {
       
public void run() {
         
doSomeStuff();
       
}
      })
;
   
long start = System.currentTimeMillis();
   
for (int i = 0; i < threads.length; i++)
     
threads[i].start();
   
for (int i = 0; i < threads.length; i++)
     
threads[i].join();
   
long end = System.currentTimeMillis();
    System.out.println
("Time: " + (end - start) + "ms");
 
}
}

Метод doSomeStuff() предназначен для порождения выполняющих какое-то действие потоков. Мы можем оценить планируемые издержки для нескольких потоков времени исполнения StupidThreadBenchmark. Однако компилятор может определить, что весь код в doSomeStuff является "мертвым", и оптимизировать его путем удаления, поскольку uselessSum никогда не используется. Как только код внутри цикла удаляется, может удалиться также и сам цикл, оставив doSomeStuff полностью пустым. В таблице 1 показана производительность StupidThreadBenchmark на клиенте и на сервере. Обе JVM показывают линейную зависимость от количества потоков. Легко может быть выдвинуто ошибочное предположение о том, что JVM сервера в 40 раз быстрее JVM клиента. В действительности происходит следующее - серверный компилятор выполняет более полную оптимизацию и может обнаружить, что doSomeStuff содержит полностью "мертвый" код. В то время как многие программы действительно ускоряются на JVM сервера, ускорение, которое видите вы - это просто измерение плохо написанного теста производительности, а не блестящая производительность JVM сервера. Но если вы были невнимательны, можно довольно легко перепутать одно с другим.

Таблица 1. Производительность StupidThreadBenchmark в клиентской и серверной JVM

Количество потоков Время работы JVM клиента Время работы JVM сервера
10 43 2
100 435 10
1000 4142 80
10000 42402 1060

Оптимизация (т.е. удаление) "мертвого" кода также является проблемой при тестировании компилированных статически приложений. Однако определить удаление компилятором большого фрагмента вашего кода в таких приложениях намного легче. Вы можете посмотреть на сгенерированный машинный код и обнаружить отсутствие части вашей программы. При использовании же динамически компилируемых языков эта информация не является для вас легкодоступной.

Тренировка

При измерении производительности идиомы Х вы, обычно, хотите измерить производительность ее компилированной, а не интерпретируемой, версии. (Вы хотите знать, насколько быстро X будет работать.) Для этого перед началом измерений времени выполнения необходима "тренировка" JVM - выполнение вашего действия достаточное количество раз, для того чтобы компилятор имел достаточно времени и заменил интерпретируемый код компилированным.

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

С современными динамическими компиляторами это сделать значительно труднее. Компилятор запускается в менее предсказуемое время, JVM по своему желанию переключает код с интерпретируемой версии на компилированную, а один и тот же code path при выполнении может быть компилирован и перекомпилирован более одного раза. Если вы не определите время этих событий, они могут серьезно исказить результаты ваши х измерений.

На рисунке 1 показаны некоторые из возможных искажений из-за непредсказуемой динамической компиляции. Допустим, вы собираетесь измерить 200000 итераций цикла и компилированный код в 10 раз быстрее интерпретируемого. Если компиляция запускается за 200000 итераций, вы измеряете только интерпретируемую производительность (шкала времени (a)). Если компиляция запускается за 100000 итераций, общее время выполнения состоит из времени выполнения 100000 интерпретируемых итераций плюс время компиляции (которое вы не хотите учитывать) плюс время выполнения компилируемых итераций (шкала времени (b)). Если компиляция запускается за 20000 итераций, общее время состоит из 20000 интерпретируемых итераций плюс время компиляции плюс 180000 компилированных итераций (шкала времени (c)). Поскольку вы не знаете,когда запускается компилятор, а также время компиляции, результат измерений может сильно искажаться. В зависимости от времени компиляции и того, насколько компилируемый код быстрее интерпретируемого, небольшие изменения количества итераций могут привести к большим различиям в измерении "производительности".

Итак, какого объема тренировки достаточно? Вы не знаете. Лучшее, что можно сделать - запустить ваш тест с параметром -XX:+PrintCompilation, найти причину запуска компилятора, затем реструктуризировать вашу программу измерения производительности так, чтобы вся компиляция происходила до начала измерений и не вызывалась в середине измерительного цикла.

Не забывайте о сборке мусора

Как вы уже видели, для получения точных результатов измерения нужно выполнить тестирующий код даже большее количество раз, чем было бы достаточно для тренировки JVM. С другой стороны, если тестирующий код производит любое размещение объектов (почти каждый код это делает), из них создается мусор и со временем должен будет запуститься сборщик мусора. Это еще один элемент, который может сильно исказить результаты измерений - небольшое изменение количества итераций может означать работу сборщика мусора, который искажает измерение "времени итерации".

Если запустить тест с параметром -verbose:gc, то можно определить время, затрачиваемое на сборку мусора, и соответственно скорректировать данные измерений. Еще лучше запустить программу на очень длительное время, гарантируя инициирование большого числа сборок мусора для более точной компенсации действий по размещению объектов и сборке мусора.

Динамическая деоптимизация

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

Листинг 2 представляет пример типа оптимизации, разрешаемой через встраивание. Метод outer() вызывает inner() с аргументом null, который приводит к тому, что inner() не выполняет никаких действий. Но, встраивая вызов к inner(), компилятор может обнаружить, что ветвление else в inner() является "мертвым" кодом и может оптимизировать проверку и ветвление else путем удаления, что может в свою очередь дать возможность оптимизировать (удалить) вызов к inner() полностью. Если бы inner() не был бы встроен, такая оптимизация не была бы возможна.

Листинг 2. Как встраивание может привести к лучшей оптимизации "мертвого" кода
public class Inline {
 
public final void inner(String s) {
   
if (s == null)
     
return;
   
else {
     
// выполнить что-то действительно сложное
   
}
  }

 
public void outer() {
   
String s = null;
    inner
(s);
 
}
}

К сожалению, виртуальные функции ставят препятствие для встраивания, а виртуальные функции в Java больше распространены, чем в C++. Допустим, компилятор пытается оптимизировать вызов doSomething() в следующем коде:

Foo foo = getFoo();
foo.doSomething
();

Из этого фрагмента кода компилятор не может с уверенностью определить, какая версия doSomething() будет выполнена: реализованная в классе Foo, или в субклассе Foo. Есть несколько ситуаций, когда ответ очевиден (например, если Foo указан как final или doSomething() определен как final-метод в Foo), но чаще всего компилятор должен догадываться. Со статическим компилятором, который компилирует один класс в данный момент времени, нам бы не повезло. Но динамический компилятор может использовать глобальную информацию для принятия лучшего решения. Допустим, что не существует в данном приложении расширяющих Foo загруженных классов. Эта ситуация больше похожа на тот случай, когда doSomething() являлся final-методом Foo - компилятор может преобразовать вызов виртуального метода в прямую передачу (уже улучшение) и, далее, может также выполнить встраивание doSomething(). (Преобразование вызова виртуального метода в прямой вызов метода называется мономорфным преобразованием вызова.)

Подождите секунду - классы могут быть загружены динамически. Что произойдет если компилятор выполнит такую оптимизацию, а затем загрузится класс, расширяющий Foo? Более того, что если это будет сделано внутри метода генератора getFoo(), и getFoo() возвратит экземпляр нового субкласса Foo? Не будет ли сгенерированный код ошибочным? Да, будет. Но JVM понимает это и сделает недействительным сгенерированный код, основанный на теперь уже неверном предположении, и возвратится к интерпретации (или перекомпилирует неверный code path).

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

Странный результат

В листинге 3 приведен шаблон кода, в котором объединены неправильная тренировка, мономорфное преобразование вызова и деоптимизация, что приводит к полностью бессмысленным, но легко интерпретируемым результатам:

Листинг 3. Результаты тестовой программы искажены мономорфным преобразованием вызова и последовательной деоптимизацией

public class StupidMathTest {
 
public interface Operator {
   
public double operate(double d);
 
}

 
public static class SimpleAdder implements Operator {
   
public double operate(double d) {
     
return d + 1.0;
   
}
  }

 
public static class DoubleAdder implements Operator {
   
public double operate(double d) {
     
return d + 0.5 + 0.5;
   
}
  }

 
public static class RoundaboutAdder implements Operator {
   
public double operate(double d) {
     
return d + 2.0 - 1.0;
   
}
  }

 
public static void runABunch(Operator op) {
   
long start = System.currentTimeMillis();
   
double d = 0.0;
   
for (int i = 0; i < 5000000; i++)
     
d = op.operate(d);
   
long end = System.currentTimeMillis();
    System.out.println
("Time: " + (end - start) + "   ignore:" + d);
 
}

 
public static void main(String[] args) {
   
Operator ra = new RoundaboutAdder();
    runABunch
(ra); // неправильная попытка тренировки
   
runABunch(ra);
    Operator sa =
new SimpleAdder();
    Operator da =
new DoubleAdder();
    runABunch
(sa);
    runABunch
(da);
 
}
}

StupidMathTest сначала пытается выполнить тренировку (безуспешно), затем измеряет время выполнения SimpleAdder, DoubleAdder и RoundaboutAdder с результатами, отображенными в таблице 2. Похоже, что намного быстрее добавить 1 к double, добавив 2 и отняв 1, чем просто добавив 1 сразу же. И немного быстрее добавить 0.5 дважды, чем добавить 1. Возможно ли такое? (Ответ: нет.)

Таблица 2. Бессмысленные и обманчивые результаты StupidMathTest

Method Runtime
SimpleAdder 88ms
DoubleAdder 76ms
RoundaboutAdder 14ms

Что здесь произошло? После цикла тренировки RoundaboutAdder и runABunch() компилируются, и компилятор выполняет мономорфное преобразование вызова с Operator и RoundaboutAdder, так что первый проход выполняется довольно быстро. Во втором проходе (SimpleAdder) компилятор должен выполнить деоптимизацию и откат к диспетчеризации виртуального метода, так что второй проход выполняется медленнее благодаря неспособности оптимизировать (удалить) вызов виртуальной функции, время тратится на перекомпиляцию. На третьем проходе (DoubleAdder) выполняется меньше перекомпиляции, поэтому он работает быстрее. (В действительности компилятор собирается провести свертывание констант в RoundaboutAdder и DoubleAdder, генерируя точно такой же код, что и SimpleAdder. То есть, если существует разница во время выполнения, она не зависит от арифметического кода.) Какой проход выполнится первый, он и будет самым быстрым.

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

Заключение

Результаты приведенных здесь примеров были настолько очевидно ошибочными, что было ясно - должно происходить еще нечто. Даже меньшие эффекты могут легко исказить результаты ваших программ тестирования производительности без включения вашего детектора "Что-то здесь не то". И хотя здесь представлены распространенные источники искажений микротестов производительности, существует множество других источников. Мораль этой истории: вы не всегда измеряете то, что думаете. Фактически, вы обычно не измеряете то, что думаете. Будьте очень осторожны с результатами любых измерений производительности, в которые не были вовлечены реальные программы на длительный период времени.

Ресурсы

Ссылки по теме

Jikes Research Virtual Machine
Overview of the IBM Just-in-Time Compiler
Java theory and practice series

Об авторах

Brian Goetz является профессиональным разработчиком программного обеспечения более чем 17 лет. Он - главный консультант в Quiotix, компании по разработке программного обеспечения и консультациям в Los Altos, Calif., и работает в нескольких экспертных группах JCP. Ищите опубликованные и готовящиеся им статьи в популярных отраслевых публикациях.