Много-много лет назад (в середине 2000-х), когда я вскоре закончил университет и получил свою первую работу в частном бизнесе, мы работали над большой бизнес-системой, написанной на Java, которая должна была принимать и обрабатывать миллионы и миллионы данных. EPC (сертификаты энергоэффективности). Эти EPC были отправлены нам в виде XML-документов из разных организаций.

В те мрачные годы многим (другим) разработчикам нравилось обращаться с XML как с «просто строками», и нередко можно было увидеть множество XML-элементов, жестко запрограммированных в виде строк внутри Java-приложений, с большим использованием функции подстроки и конкатенации для создания XML-документов. В это темное время не только XML подвергался огромным злоупотреблениям, но, что еще более печально, так же, как и кодировка символов!

Мы ожидали получить допустимый правильно сформированный XML с использованием кодировки символов, которая соответствовала заявленной в XML-объявлении, обычно UTF-8. Мы часто получали XML-документы, которые не были ни в ожидаемой по умолчанию кодировке (UTF-8), ни в кодировке, которую они объявили, например. ИСО 8859–1. Давайте даже не будем упоминать BOM (Byte Order Marks). Эта проблема возникла еще до того, как мы смогли проверить, правильно ли сформирован XML-документ или он действителен по отношению к любым предписанным схемам XML.

В лучшем случае синтаксический анализатор XML будет жаловаться и сообщать нам, что в XML-документе есть какая-то проблема с кодировкой символов, но гораздо более вероятно, что синтаксический анализ завершится, и позже разработчик будет просматривать XML или бизнес-аналитик. посмотрите на отчет или PDF-файл, созданный из XML, и задайтесь вопросом, что это были за странные или поврежденные символы!

Чтобы попытаться обнаружить эти проблемы на ранней стадии, в свободное время я написал небольшой инструмент с открытым исходным кодом, который мог проверить, что файл является допустимым UTF-8. Мы использовали этот довольно простой инструмент для предварительной проверки входящих XML-документов и проверки того, что их кодировки символов соответствуют ожидаемым, немедленно отклоняя любые не прошедшие проверку. Это позволило нам выявить проблемы на ранней стадии и значительно сократить ненужную обработку плохих документов.

Этот инструмент впервые стал общедоступным много лет спустя, при создании нового Цифрового архива в Национальном архиве. Я пожертвовал этот код их усилиям Digital Preservation, и он появился на их GitHub: UTF-8 Validator.

Введите GraalVM

В то время как исходный валидатор UTF-8 был очень простым и выполнял свою работу… он был быстрым, но не быстрым! Я часто думал потратить некоторое время на то, чтобы сделать его быстрее, но никогда не находил необходимых часов.

Я некоторое время вскользь следил за развитием GraalVM и читал различные статьи на Hacker News по мере их поступления. GraalVM — это новая виртуальная машина-полиглот, которая может выполнять код как с Java, так и с других языков. GraalVM включает в себя ряд новых оптимизаций помимо тех, которые предоставляет JVM, например, комплексный Escape-анализ.

Я давно хотел поэкспериментировать с Graal и, в частности, с его native-image инструментом, который позволяет вам AOT (Ahead Of Time) компилировать ваш код Java в собственный машинный код; Основное преимущество, по-видимому, заключается в том, что вы можете избежать времени запуска JVM (виртуальная машина Java), а также любых итераций прогрева, которые необходимы для того, чтобы критические части вашего приложения были JIT (Just In Time), скомпилированные в собственный машинный код.

Graal native-image имеет некоторые ограничения на то, что он поддерживает, например, поддерживаются не все отражения Java, он также не может поддерживать динамическую загрузку и выгрузку классов. Мой инструмент UTF-8 Validator показался мне подходящим для экспериментов с Graal, поскольку он полностью написан на Java и не имеет внешних зависимостей.

Если мы проигнорируем всю сложную научную работу и просто будем пускать слюни на заявления о повышении производительности в различных статьях в Интернете, мы ожидаем получить неплохой прирост производительности только от запуска нашего приложения на GraalVM по сравнению с JVM OpenJDK 8.

Моя тестовая машина — довольно стандартный MacBook Pro середины 2015 года с 1 ТБ SSD, 16 ГБ ОЗУ и macOS High Sierra (10.13.6). У меня есть OpenJDK 1.8.0_172 и GraalVM EE 1.0.0-rc7 (также основанный на OpenJDK 1.8.0_172). Для тестирования скорости проверки я использую XML-файл в кодировке UTF-8 размером 269 703 903 байт (~ 257 МБ) из PubMed.

Сначала мы запускаем валидатор UTF-8 с JVM OpenJDK:

$ zulu8.30.0.1-jdk8.0.172-macosx_x64/bin/java -jar target/utf8-validator-1.3-SNAPSHOT.jar pubmed-257m.xml
Validating: pubmed-257m.xml
Valid OK (took 18323ms)

Затем для сравнения запускаем UTF-8 Validator с GraalVM:

$ graalvm-ee-1.0.0-rc7/Contents/Home/bin/java -jar target/utf8-validator-1.3-SNAPSHOT.jar pubmed-257m.xml
Validating: pubmed-257m.xml
Valid OK (took 17365ms)

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

Таким образом, мы действительно видим, что процесс выполняется быстрее в GraalVM, чем в JVM, с сокращением времени обработки примерно на 5,23%. Возможно, не увеличение производительности, на которое мы очень надеялись! Итак, как насчет создания нативного образа?

Родной образ Graal

Мы можем добавить следующий профиль в pom.xml валидатора UTF-8, чтобы Maven создавал собственное изображение:

<profile>
     <id>native</id>
     <build>
         <plugins>
             <plugin>
                 <groupId>org.codehaus.mojo</groupId>
                 <artifactId>exec-maven-plugin</artifactId>
                 <executions>
                     <execution>
                         <phase>package</phase>
                         <goals>
                             <goal>exec</goal>
                         </goals>
                     </execution>
                 </executions>
                 <configuration>
                     <executable>native-image</executable>
                     <workingDirectory>${project.build.directory}</workingDirectory>
                     <arguments>
                         <argument>-da</argument>
                         <argument>--class-path</argument>
                         <classpath/>
                         <argument>uk.gov.nationalarchives.utf8.validator.Utf8ValidateCmd</argument>
                         <argument>utf8validate</argument>
                     </arguments>
                 </configuration>
             </plugin>
         </plugins>
     </build>
 </profile>

Если мы сейчас запустим mvn clean compile package -P native, мы найдем собственный исполняемый файл по пути target/utf8validate. Это довольно удивительно, так как теперь нам даже не нужна JVM для запуска нашего приложения! Как производительность сравнивается:

$ target/utf8validate pubmed-257m.xml
Validating: pubmed-257m.xml
Valid OK (took 28198ms)

Хм… родной образ валидатора UTF-8 на самом деле медленнее. По сравнению с JVM он на ~53,89% медленнее, а по сравнению с GraalVM — на ~62,38%. Это не совсем то, что я ожидал. Тогда может показаться, что проблема с производительностью нашего UTF-8 Validator на самом деле не связана ни со временем запуска JVM, ни с отсутствием JIT-компиляции.

И последнее, что мы можем попробовать, это использовать PGO (Profile Guided Optimization) с инструментом native-image. По сути, мы компилируем собственный образ, который при запуске создает профиль работающего приложения, затем мы снова компилируем его с помощью этого руководства по профилю, чтобы создать дополнительный оптимизированный собственный образ.

Чтобы получить руководство по профилю, нам нужно добавить аргумент --pgo-instrument при первом вызове компиляции native-image. Затем, запустив профиль, собирающий собственное изображение, мы видим гораздо более низкую производительность, чего и следовало ожидать, поскольку он собирает и записывает данные профиля в файл default.iprof:

$ target/utf8validate pubmed-257m.xml
Validating: pubmed-257m.xml
Valid OK (took 43145ms)
$ ls -la default.iprof -rw-r--r-- 1 aretter wheel 424208 7 Oct 18:09 default.iprof

Чтобы использовать руководство по профилю, нам нужно переместить файл default.iprof в папку target/, добавив аргумент --pgo во второй раз, когда мы вызовем компиляцию native-image:

$ mvn clean compile
$ mv default.iprof target/ $ mvn package -P native

Результирующий собственный образ PGO при запуске дает нам:

$ target/utf8validate pubmed-257m.xml
Validating: pubmed-257m.xml
Valid OK (took 21989ms)

Хотя собственный образ PGO примерно на 22% быстрее, чем собственный образ без PGO, к сожалению, он все еще медленнее, чем JVM и GraalVM. По сравнению с JVM он на ~20% медленнее, а по сравнению с GraalVM — на ~26,63%.

Вывод

Хотя вполне вероятно, что валидатор UTF-8 тратит большую часть своего времени либо на сравнение байтов, либо на операции ввода-вывода на диске, и маловероятно, что запуск JVM является самой большой затратой, я все же ожидал, что собственный образ будет быстрее, чем работает через JVM. Мне пока непонятно, почему он не работает быстрее, и у меня скорее закончилось личное время, чтобы исследовать это прямо сейчас. Я буду продолжать следить за развитием GraalVM, так как считаю, что у него огромный потенциал. Я также надеюсь, что в ближайшем будущем у меня будет время вернуться к этому и понять, почему нативное изображение работает медленнее.

Первоначально опубликовано на сайте blog.adamretter.org.uk 7 октября 2018 г.