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

Существуют ли другие виды?

В выпуске Теория и практика Java за этот месяц ведущий рубрики Брайан Гетц проводит исследование вопроса, почему выполнить оценку производительности конструкций языка Java на самом деле гораздо сложнее, чем кажется на первый взгляд.

Брайан Гетц, главный консультант, Quiotix

Брайан Гетц (Brian Goetz) - консультант по ПО и последние 15 лет работал профессиональными разработчиком ПО. Сейчас он является главным консультантом в фирме Quiotix, занимающейся разработкой ПО и консалтингом и находящейся в Лос-Альтос, Калифорния. Следите за публикациями Брайана в популярных промышленных изданиях. Вы можете связаться с Брайаном по адресу brian@quiotix.com



29.01.2007

Даже тогда, когда производительность не является ключевым, или даже просто заявленным, требованием, предъявляемым к проекту над которым вы трудитесь, очень сложно не обращать внимание на вопросы производительности, так как у вас может появиться мысль, что такой подход сделает из вас "плохого инженера". Заканчивая создание высокопроизводительного кода, зачастую разработчики пишут маленькие тестовые программки для измерения относительной прозводительности разных подходов. К сожалению, как вы узнали из декабрьского выпуска " Динамическая компиляция и управление производительностью (Dynamic compilation and performance management)", оценить производительность определенного словосочетания или конструкции в языке Java гораздо сложнее, чем в других, статически компилируемых языках.

Некорректный микротест оценки производительности

После выхода в свет моей октябрьской статьи "Более гибкое, масштабируемое блокирование в JDK 5.0 (More flexible, scalable locking in JDK 5.0)," мой коллега прислал мне SyncLockTest - программу оценки производительности (показана в листинге1), которая была предназначена для определения, "что быстрее": синхронизированные (synchronized) базисные элементы или новый класс ReentrantLock. Запустив ее на своем ноутбуке, он пришел к выводу, что синхронизация быстрее, вопреки заключению, приведенному в статье, и предоставил свой микротест в качестве "доказательства." Весь процесс - проектирование микротеста оценки производительности, его реализация, выполнение и толкование полученных результатов содержали дефекты. В данном случае, ситуация с моим коллегой, достаточно умным парнем, который активно работал над кодом, является доказательством того, как сложно иметь дело с оценкой производительности.

Листинг 1. Некорректный микротест оценки производительности SyncLockTest

Кликните, чтобы увидеть код

Некорректный микротест оценки производительности

Дефекты в концепции

Дефекты в методологии

Дефекты в реализации

Код программы оценки производительности не похож на настоящий код

Принцип Heisenbenchmark

Удаление невыполняемых участков кода

Развертка цикла и объединение блокировок

Перечень дефектов

Какой вопрос - такой ответ

Как создать совершенный микротест оценки производительности

Вы шутите?

Итак, что делать?

 interface Incrementer { void
				increment(); } class LockIncrementer implements Incrementer { private long counter =
				0; private Lock lock = new ReentrantLock(); public void increment() { lock.lock();
				try { ++counter; } finally { lock.unlock(); } } } class SyncIncrementer implements
				Incrementer { private long counter = 0; public synchronized void increment() {
				++counter; } } class SyncLockTest { static long test(Incrementer incr) { long start
				= System.nanoTime(); for(long i = 0; i < 10000000L; i++) incr.increment();
				return System.nanoTime() - start; } public static void main(String[] args) { long
				synchTime = test(new SyncIncrementer()); long lockTime = test(new
				LockIncrementer()); System.out.printf("synchronized: %1$10d\n",
				synchTime); System.out.printf("Lock: %1$10d\n", lockTime);
				System.out.printf("Lock/synchronized = %1$.3f",
				(double)lockTime/(double)synchTime); } }

SyncLockTest определяет два выполнения интерфейса и использует System.nanoTime() для определения времени выполнения каждой реализации 10,000,000 раз. Каждая реализация увеличивает счетчик на 1 в поточно-ориентированной манере; один использует встроенную синхронизацию, а второй - новый класс ReentrantLock. Цель заключалась в том, чтобы получить ответ на вопрос, "что быстрее - синхронизация или ReentrantLock?" Давайте узнаем почему данная программа оценки производительности, кажущаяся безобидной, не способна измерить то, для чего предназначена, и измеряет ли она вообще что-нибудь полезное.

Дефекты в концепции

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

Хорошо известен факт, что в ранних версиях виртуальной машины Java синхронизация без конфликтов была достаточно медленной. Однако с тех времен производительность такой синхронизации была существенно увеличена. (Описание технологий, применяемых в виртуальной машине Java для оптимизации производительность синхронизации без конфликтов, приведено в разделе Ресурсы.) С другой стороны, синхронизация с конфликтами до сих пор является более дорогой, чем синхронизация без конфликтов. Когда оспаривается блокировка не только на долю виртуальной машины Java выпадает поддержка очереди ждущих потоков, но она должна использовать вызовы для блокирования и разблокирования потоков, которые не могут сразу запросить блокировку. Более того, те приложения, которые испытывают более высокую степень соперничества, обычно показывают более низкую производительность не только потому, что больше времени тратится на составление расписания потоков и меньше на реальную работу, но также и потому, что центральные процессоры могут простаивать, пока потоки заблокированы в ожидании блокирвки. Тестирующая программа для измерения производительности базисных элементов синхронизации должна принимать во внимание реальное положение в области конфликтных ситуаций.

Дефекты в методологии

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

Дефекты в реализации

Что касается реализации, то SyncLockTest игнорирует набор аспектов динамической компиляции. Как вы уже видели в декабрьском выпуске, HotSpot JVM сначала выполняет код в режиме интерпретации, а компилирует его в машинный код только после определенного числа выполнений. Отсутствие должного "разогрева" виртуальной машины Java может привести к значительному искажению данных о производительности. Во-первых, время, затраченное JIT-компилятором на анализ и компиляцию кода, включается во время выпо нения теста. И что еще более важно, если компиляция выполняется в середине тестового прогона, то результатом тестирования будет сумма из интерпретируемого выполнения, времени компиляции JIT, оптимизированного выполнения, которая в итоге не предоставляет Вам адекватной оценки о реальной производительности вашего кода. А если код не скомпилирован до запуска тестирующей программы, и не компилируется во время тестирования, то будет интерпретироваться целостность тестового прогона, что в свою очередь, тоже не дает объективной оценки производительности того словосочетания, которое вы проверяете.

SyncLockTest также стал жертвой проблемы подключения и деоптимизации, которая обсуждалась в декабре, когда первый временной проход оценивает код, который был агрессивно подключен при помощи мономорфной трансформации вызова, а второй проход оценивает код, который был впоследствии деоптимизирован благодаря загрузке виртуальной машиной Java другого класса, расширяющего тот же самый базовый класс или интерфейс. При вызове метода временного тестирования с реализацией SyncIncremente r период прогона считает, что был загружен только один класс, реализующий Incrementer, и преобразует вызовы виртуального метода increment()в вызовы прямого методаSyncIncrementer. При вызове метода временного тестирования с реализациейLockIncrementer, то test() будет перекомпилирован для использования вызовов виртуального метода, что означает, что второй проход через метод test() будет на каждой итерации выполнять больше действий, чем первый, превращая тем самым наш тест в "сравнение яблок и апельсинов". Это может серьезно исказить результаты и привести к тому, что любой вариант, запускаемый первым, будет казаться более быстрым.


Код программы оценки производительности не похож на настоящий код

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

Принцип Heisenbenchmark

При написании микротеста оценки производительности таких базисных элементов языка, как например, синхронизация, вы обязательно столкнетесь с принципом Heisenberg. Так как вы хотите измерить скорость выполнения операции X, то вас не интересует ничего кроме самой X. Но зачастую результатом является ничего не делающая программа оценки производительности, которую без вашего ведома компилятор может оптимизировать частично или полностью, заставляя тест работать гораздо быстрее, чем ожидалось. Если в программу оценки производительности добавить сторонний код Y, то вы будете измерять производительность X+Y, и тем самым внесете искажения в измерение X, или еще хуже, наличие Y изменит способ оптимизации X JIT-компилятором. Создание хорошей программы оценки производительности обусловлено достижением неуловимого баланса между недостаточным количеством заполнителя и зависимости от потока данных, чтобы оградить компилятор от оптимизации всей вашей программы, и избыточным количеством заполнителя, при котором то, что вы пытаетесь измерить просто теряется в помехах.

Так как компиляцией во время прогона используются данные профилирования, то JIT-компилятор может оптимизировать тестовый код не так как реальный код. Что касается всех программ оценки производительности, то всегда есть риск, что компилятор сможет провести оптимизацию всего кода, так как придет к выводу (верному), что код программы оценки производительности фактически ничего не делает или результат его работы больше нигде не используется. Чтобы написать эффективную программу оценки производительности необходимо "обмануть" компилятор, заставив его не удалять код как неиспользуемый, хотя он и является таковым на самом деле. Использование переменных-счетчиков в классах Incrementer – неудачная попытка "обмануть" компилятор, так как зачастую, когда дело касается устранения неиспользуемого кода, компиляторы гораздо умнее, чем мы от них ожидаем.

Проблема осложняется тем, что синхронизация является встроенной особенностью языка. Для того чтобы снизить затраты производительности JIT-компиляторам позволено проявлять некоторую свободу в отношении синхронизированных блоков. Есть случаи, когда синхронизацию можно удалить совсем, а смежные блоки синхронизации, которые синхронизируют на одном мониторе – объединить. Если вы оцениваете стоимость синхронизации, то такая оптимизация действительно наносит ущерб, так как вы не знаете, сколько синхронизаций (в данном случае, есть вероятность что все) были оптимизированы таким образом. Еще хуже то, что JIT-компилятор может оптимизировать ничего не делающий код в SyncTest.increment() не так как реальную программу.

Все может быть еще хуже. Очевидная цель данной программы оценки производительность заключалась в том, чтобы протестировать что быстрее: синхронизация илиReentrantLock. Так как синхронизация встроена в язык, аReentrantLockявляется обычным классом Java, то компилятор будет по-разному оптимизировать пустую синхронизацию и пустой ReentrantLock. Благодаря такой оптимизации будет казаться, что пустая синхронизация работает быстрее. Так как компилятор оптимизирует эти два варианта не только по-разному, но и не так, как в реальных условиях, то результаты работы данной программы на самом деле не дадут нам много информации о различии в их относительной производительности в реальных условиях.

Удаление невыполняемых участков кода

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

Подразумевается, что два класса Incrementer ничего не делают (увеличение значения переменной). Но разумная виртуальная машина Java заметит, что значения переменных-счетчиков нигде не используются, и, следовательно, удалит ту часть кода, которая их увеличивает. И вот здесь и возникает серьезная проблема – теперь синхронизированный блок в методе SyncIncrementer.increment() пуст, и компилятор сможет удалить его полностью, а так как LockIncrementer.increment() все еще содержит блокирующий код, который компилятор сможет или не сможет удалить полностью. Может возникнуть мысль, что этот код в пользу синхронизации - так как компилятор может легко удалить его – но на самом деле, это скорее всего произойдет в ничего не делающих программах оценки производительности, чем в реальном, хорошо написанном коде.

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

Развертка цикла и объединение блокировок

Даже если компилятор не удаляет управление счетчиком, он все же может принять решение и по-разному оптимизировать два метода increment(). Стандартной оптимизацией является развертка цикла – чтобы сократить число переходов компилятор разворачивает цикл. Количество подлежащих развертке итераций зависит от количества операций в теле цикла, а в теле цикла LockIncrementer.increment() больше операций, чем в SyncIncrementer.increment(). Затем, когда SyncIncrementer.increment() развернут и метод вызывается линейно, то развернут ый цикл будет представлять из себя последовательность блоков "блокировка-приращение-разблокировка". Так как все они блокируют один и тот же монитор, то компилятор может выполнить объединение блокировок (так же называется укрупнением блокировок) чтобы объединить смежные блоки synchronized. Это означает, что SyncIncrementer будет выполнять меньшее количество синхронизаций, чем ожидалось. (Проблема в том. что после объединения блоков, в синхронизированном теле будет содержаться только последовательность приращений, которая может быть значительно сокращена до одной суммы. А если процесс повторяется неоднократно, то весь цикл может быть сведен к одному синхронизированному блоку с единственной операцией "counter=10000000". Такие оптимизации могут выполняться и настоящими виртуальными машинами Java.)

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

Перечень дефектов

Данный список не является исчерпывающим, однако в нем представлены некоторые причины того, почему программа оценки производительности не выполняет того, что задумано разработчиком:

  • Не был выполнен "разогрев" и не было учтено время, затраченное JIT-компилятором.
  • Тест был подвержен ошибкам, порожденным мономорфной трансформацией вызова и последующей деоптимизацией.
  • Код, защищенный синхронизированным блоком или ReentrantLock, фактически "мертв" и вносит изменения в оптимизацию кода JIT-компилятором; он может даже полностью устранить тест синхронизации.
  • Тестирующая программа предназначена для определения производительности базисного элемента блокировки, но не учитывает влияние конфликтов и запущена только на однопроцессорной системе.
  • Тестовая программа не запускалась на различных платформах.
  • Компилятор сможет оптимизировать тест синхронизации в большей степени, чем тест ReentrantLock, но не так же как реальные программы, использующие синхронизацию.

Какой вопрос - такой ответ

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

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

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

Для программ оценки производительности, прогоняемых на разных платформах как часть процесса тестирования JSR 166 (java.util.concurrent) форма кривых производительности значительно различается. Стоимость компонентов аппаратного обеспечения, например CAS, отличается для разных платформ с разным количеством процессоров (например, на однопроцессорных системах никогда не будет отказа CAS). Производительность памяти одного процессора P4 с технологией hyperthreading (два ядра на одном кристалле) выше, чем двух процессоров P4, и в обоих случаях характеристики производительности отличны от наращиваемой процессорной архитектуры. Таким образом, оптимальным решением будет создать "стандартные" примеров и их прогнать на типичном аппаратном обеспечении, и надеяться получить какую-либо информацию относительно производительности реальных программ на реальном аппаратном обеспечении. Что подразумевается под "стандартным" примером? "Стандартный" пример представляет собой набор из вычислений, ввода/вывода, синхронизации и конфликтных ситуаций, чей адрес памяти, линия поведен я расположения, переключение процессов, системные вызовы и связь между потоками приближены к реальной программе.


Как создать совершенный микротест оценки производительности

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

Вы шутите?

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

Итак, что делать?

Что же делать, если вы действительно хотите узнать что быстрее - синхронизация или иной механизм блокировки (или ответ на любой аналогичный вопрос о микро-производительности)? Один из вариантов (который не подходит большинству разработчиков) – "поверить экспертам". Разрабатывая класс ReentrantLock члены JSR 166 EG потратили сотни, если не тысячи, часов на тестирование производительности на различных платформах, изучение машинного кода, сгенерированного JIT-компилятором, и тщательный анализ полученных результатов. Затем они поправили код и повторили все заново. Полученный практический опыт и детальное понимания поведения JIT-компилятора и микропроцессора легли в основу развития и анализа этих классов, которые, к сожалению, невозможно получить из анализа результатов работы одной программы оценки производительности, как бы нам этого не хотелось. В качестве второго варианта можно обратить свое внимание на "макро" программы оценки производительности – написать несколько настоящих программ, закодировать их обоими способами, выстроить настоящую стратегию генерации нагрузки, оценить производительность вашего приложения обоими способами при настоящих режимах нагрузки и в реальных условиях применения. Это очень большой труд, но он позволит вам стать ближе к ответу на интересующий вас вопрос.

Ресурсы

Комментарии

developerWorks: Войти

Обязательные поля отмечены звездочкой (*).


Нужен IBM ID?
Забыли Ваш IBM ID?


Забыли Ваш пароль?
Изменить пароль

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Профиль создается, когда вы первый раз заходите в developerWorks. Информация в вашем профиле (имя, страна / регион, название компании) отображается для всех пользователей и будет сопровождать любой опубликованный вами контент пока вы специально не укажите скрыть название вашей компании. Вы можете обновить ваш IBM аккаунт в любое время.

Вся введенная информация защищена.

Выберите имя, которое будет отображаться на экране



При первом входе в developerWorks для Вас будет создан профиль и Вам нужно будет выбрать Отображаемое имя. Оно будет выводиться рядом с контентом, опубликованным Вами в developerWorks.

Отображаемое имя должно иметь длину от 3 символов до 31 символа. Ваше Имя в системе должно быть уникальным. В качестве имени по соображениям приватности нельзя использовать контактный e-mail.

Обязательные поля отмечены звездочкой (*).

(Отображаемое имя должно иметь длину от 3 символов до 31 символа.)

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Вся введенная информация защищена.


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=Технология Java
ArticleID=192671
ArticleTitle=Теория и практика Java: Анатомия некорректных микротестов оценки производительности
publish-date=01292007