верификация байт кода означает следующее
Введение в байт-код Java
May 15 · 6 min read
Каждому Java-разработчику известно, какую роль в экосистеме языка играет JVM. Однако большинство не разбирается в том, как работает JVM под капотом. Хотя для разработки на Java это не обязательно, код станет лучше, если вы глубже поймете JVM, потому что так вы будете знать, как каждая строка кода влияет на процессы внутри JVM.
Однако для начала нужно понять, что такое байт-код. Итак, поговорим о вводе и выводе байт-кода Java и о том, как он влияет на JVM во время запуска программы.
Что такое байт-код Java?
Если в какой-то момент профессиональной жизни вы слышали, как проповедуют независимость Java-программ от платформ, скажите спасибо байт-коду.
Как генерируется байт-код?
Как посмотреть байт-код Java?
Если вам хочется увидеть сам байт-код, простейший способ — воспользоваться командной строкой.
Как работает JVM
Прежде чем углубляться в байт-код, стоит понять, как JVM его обрабатывает.
Методы — одна из важнейших составляющих кода для JVM. Среда выполнения Java-программы — это, по сути, набор методов, вызываемых JVM. JVM создает фрейм для каждого такого метода и помещает созданный фрейм наверх стека текущего потока для выполнения.
Фрейм состоит из локальной среды, которая необходима для поддержания его выполнения. Как правило он содержит массив локальных переменных и стек операндов. Посмотрим, что эти элементы из себя представляют.
Массив локальных переменных
Массив локальных переменных, как следует из названия, нужен для хранения локальных переменных в методе. Также он хранит аргументы, которые принимает метод.
Определим два метода: один статический и один метод экземпляра, но схожие во всем остальном.
Локальные массивы переменных для этих методов будут выглядеть следующим образом:
Стек операндов
Стек операндов — это рабочее пространство внутри фрейма метода. Поскольку это стек, вы можете помещать и забирать значения только из верхней его части. Большинство инструкций байт-кода, принадлежащих определенному методу, либо участвуют в помещении значений в стек, либо забирают значения из стека для обработки.
Инструкция байт-кода load и ее расширения нужны для перемещения значения, хранящегося в массиве переменных, в стек. Инструкция store применяется для извлечения значений из стека и сохранения в массиве переменных. Существуют и другие инструкции, которые извлекают значения из стека для обработки.
Посмотрим в байт-код
Ради возможности вглядеться в байт-код, я написал простой Java-класс:
Деконструкция байт-кода
Здесь важно отметить еще одно: индексы, заданные инструкциям байт-кода — как видим, они не увеличиваются на единицу для каждой новой инструкции.
Число перед инструкцией указывает на индекс ее начального байта. А любой байт-код состоит из однобайтовых опкодов, за которыми следует ноль или более операндов.
Вывод
Надеюсь, вам удалось узнать кое-что новое о том, как работает байт-код Java. С этим более четким знанием вы сможете лучше писать код. Можете даже поэкспериментировать с самим байт-кодом во время выполнения программы, воспользовавшись такими библиотеками, как ASM.
Верификация Java байт-кода: когда, как, а может отключить?
Мне не понравилось
О спикере
Один из инициаторов и руководителей проекта Excelsior JET, сертифицированной реализации Java SE, разрабатываемой компанией Excelsior. Работая над проектом с 1997 года, поучаствовал в исследовании и разработке практически всех компонент продукта от ядра до продуктовых свойств. В последнее время также экспериментирует с open source проектами, связанными c концепциями и подходами к новому вебу.
О докладе
Сегодня Java разработчики все чаще используют библиотеки для порождения Java байт-кода в рантайме. Делается это для эффективной реализации различных трюков, которые сложно или невозможно выразить на языке Javа. Когда используется Java, компилятор javac гарантирует, что на выходе получится корректный Java байт-код. Но, спускаясь на уровень непосредственно байт-кода, часто нужно самостоятельно следить за его корректностью. Иначе при загрузке порожденных классов на выходе будет j.l.VerifyError, потому что JVM посредством верификатора Java байт-кода строго следит за корректностью байт-кода, который она загружает. Таким образом, порождая байт-код, недостаточно просто знать семантику байт-кодных инструкций — нужно также знать, как работает Java byte-code верификатор, какой байт-код он считает корректным, а какой нет.
В этом докладе Никита поможет слушателям разобраться, какую миссию в JVM несет верификатор байт-кода, когда и как он работает, может ли повлиять на производительность вашего приложения и почему опасно его отключать.
Путь к пониманию байт-кода V8
V8 — это JavaScript-движок Google с открытым кодом. Его используют Chrome, Node.js и многие другие приложения. Этот материал, подготовленный сотрудником Google Франциской Хинкельманн, посвящён описанию формата байт-кода V8. Байт-код довольно просто читать, если понять некоторые базовые вещи.
Конвейер компиляции V8
Зажигание! Пуск! Интерпретатор Ignition, название которого можно перевести как «зажигание», является частью конвейера компиляции V8 с 2016-го года
Когда V8 компилирует JavaScript-код, парсер генерирует абстрактное синтаксическое дерево. Синтаксическое дерево — это древовидное представление синтаксической структуры JS-кода. Интерпретатор Ignition генерирует байт-код из этой структуры данных. Оптимизирующий компилятор TurboFan, в итоге, генерирует из байт-кода оптимизированный машинный код.
Конвейер компиляции V8
Если вы хотите узнать о том, почему V8 имеет два режима исполнения, взгляните на моё выступление с JSConfEU.
Основы байт-кода V8
Байт-код — это абстракция машинного кода. Компилировать байт-код в машинный код проще, если байт-код спроектирован с использованием той же вычислительной модели, которая применяется в физическом процессоре. Именно поэтому интерпретаторы часто являются регистровыми или стековыми машинами.
Интерпретатор Ignition — это регистровая машина с накопительным регистром.
Код слева удобен для людей. Код справа — для машин
Анализ байт-кода функции
Теперь, после того, как мы разобрали основные понятия, посмотрим на байт-код реальной функции.
На немалую часть этих данных мы можем не обращать внимания, сосредоточившись на байт-кодах. Вот описание того, что мы тут видим.
Команда LdaSmi [1] загружает константу 1 в накопительный регистр.
LdaNamedProperty a0, [0], [4]
Теперь содержимое регистров выглядит следующим образом.
Обратите внимание на то, что байт-код, которому посвящён этот материал, используется в V8 версии 6.2, в Chrome 62 и в ещё не выпущенном Node 9. Мы, в Google, постоянно работаем над V8 в направлениях улучшения производительности и уменьшения потребления памяти. В других версиях V8 в байт-коде могут присутствовать некоторые отличия от того, что было описано здесь.
Итоги
На первый взгляд байт-код V8 может показаться довольно-таки загадочным, особенно когда он выводится с массой дополнительных сведений. Однако, как только вы узнаете о том, что Ignition — это регистровая машина с накопительным регистром, вы сможете понять назначение большинства байт-кодов.
Уважаемые читатели! Планируете ли вы анализировать байт-код ваших JS-программ?
Java Bytecode Fundamentals
Разработчики приложений на Java обычно не нуждаются в знании о байт-коде, выполняющемся в виртуальной машине, однако тем, кто занимается разработкой современных фреймворков, компиляторов или даже инструментов Java может понадобиться понимание байт-кода и, возможно, даже понимание того, как его использовать в своих целях. Несмотря на то, что специальные библиотеки типа ASM, cglib, Javassist помогают в использовании байт-кода, необходимо понимание основ для того, чтобы использовать эти библиотеки эффективно.
В статье описаны самые основы, от которых можно отталкиваться в дальнейшем раскапывании данной темы (прим. пер.).
Давайте начнём с простого примера, а именно POJO с одним полем и геттером и сеттером для него.
Когда вы скомпилируете класс, используя команду javac Foo.java, у вас появится файл Foo.class, содержащий байт-код. Вот как его содержание выглядит в HEX-редакторе:
Каждая пара шестнадцатеричных чисел (байт) переводится в опкоды (мнемоника). Было бы жестоко попытаться прочитать это в двоичном формате. Давайте перейдем к мнемоничному представлению.
Класс очень простой, поэтому будет легко увидеть связь между исходным кодом и сгенерированным байт-кодом. Первым делом мы видим, что в байт-код-версии класса компилятор вызывает конструктор по умолчанию (как и написано в спецификациях JVM).
Далее, изучая байт-кодовые инструкции (у нас это aload_0 и aload_1), мы видим, что некоторые из них имеют префиксы типа aload_0 и istore_2. Это относится к типу данных, с которыми оперирует инструкция. Префикс «a» обозначает, что опкод управляет ссылкой на объект. «i», соответственно, управляет integer.
Теперь видно, что это за странные операнды. Например, #2:
const #2 = Field #3.#18; // Foo.bar:Ljava/lang/String;
const #3 = class #19; // Foo
const #18 = NameAndType #5:#6;// bar:Ljava/lang/String;
Отметим, что, каждый код операции помечен номером (0: aload_0). Это указание на позицию инструкции внутри фрейма — дальше объясню, что это значит.
Чтобы понять, как работает байт-код, достаточно взглянуть на модель выполнения. JVM использует модель выполнения на основе стеков. Каждый тред имеет JVM-стек, содержащий фреймы. Например, если мы запустим приложение в дебаггере, то увидим следующие фреймы:
При каждом вызове метода создается новый фрейм. Фрейм состоит из стека операнда, массива локальных переменных и ссылку на пул констант класса выполняемого метода.
Размер массива локальных переменных определяется во время компиляции в зависимости от количества и размера локальных переменных и параметров метода. Стек операндов — LIFO-стек для записи и удаления значений в стеке; размер также определяется во время компиляции. Некоторые опкоды добавляют значения в стек, другие берут из стека операнды, изменяют их состояние и возвращают в стек. Стек операндов также используется для получения значений, возвращаемых методом (return values).
Байткод для этого метода состоит из трёх опкодов. Первый опкод, aload_0, проталкивает в стек значение с индексом 0 из таблицы локальных переменных. Ссылка this в таблице локальных переменных для конструкторов и instance-методов всегда имеет индекс 0. Следующий опкод, getfield, достает поле объекта. Последняя инструкция, areturn, возвращает ссылку из метода.
Так, байткод для метода getBar — 2A B4 00 02 B0. 2A относится к инструкции aload_0, B0 — к areturn. Может показаться странным, что байткод для метода имеет три инструкции, а в массиве байт 5 элементов. Это связано с тем, что getfield (B4) нуждается в двух параметрах (00 02), занимающих позиции 2 и 3 в массиве, отсюда и 5 элементов в массиве. Инструкция areturn сдвигается на 4 позицию.
Таблица локальных переменных
Для иллюстрации того, что происходит с локальными переменными, воспользуемся ещё одним примером:
Здесь две локальных переменных — параметр метода и локальная переменная int b. Вот как выглядит байт-код:
LocalVariableTable:
Start Length Slot Name Signature
0 6 0 this LExample;
0 6 1 a I
2 4 2 b I
Метод загружает константу 1 с помощью iconst_1 и ложит её в локальную переменную 2 с помощью istore_2. Теперь в таблице локальных переменных слот 2 занят переменной b, как и ожидалось. Далее, iload_1 загружает значение в стек, iload_2 загружает значение b. iadd выталкивает 2 операнда из стека, добавляет их и возвращает значение метода.
Обработка исключений
Интересный пример того, какой получается байт-код в случае с обработкой исключений, например, для конструкции try-catch-finally.
Байт-код для метода foo():
Компилятор генерирует код для всех сценариев, возможных внутри блока try-catch-finally: finallyMethod() вызывается три раза(!). Блок try скомпилировался так, как будто try не было и он был объединён с finally:
0: aload_0
1: invokespecial #2; //Method tryMethod:()V
4: aload_0
5: invokespecial #3; //Method finallyMethod:()V
Если блок выполняется, то инструкция goto перекидывает выполнение на 30-ю позицию с опкодом return.
Если tryMethod бросит Exception, будет выбран первый подходящий (внутренний) обработчик исключений из таблицы исключений. Из таблицы исключений мы видим, что позиция с перехватом исключения равна 11:
0 4 11 Class java/lang/Exception
Это перекидывает выполнение на catchMethod() и finallyMethod():
11: astore_1
12: aload_0
13: invokespecial #5; //метод catchMethod:()V
16: aload_0
17: invokespecial #3; //метод finallyMethod:()V
Если в процессе выполнения будет брошено другое исключение, мы увидим, что в таблице исключений позиция будет равна 23:
0 4 23 any
11 16 23 any
23 24 23 any
Инструкции, начиная с 23:
23: astore_2
24: aload_0
25: invokespecial #3; //Method finallyMethod:()V
28: aload_2
29: athrow
30: return
Так что finallyMethod() будет выполнен в любом случае, с aload_2 и athrow, бросающим необрабатываемое исключение.
Это всего лишь несколько моментов из области байткода JVM. Большинство было почерпнуто из статьи developerWorks Peter Haggar — Java bytecode: Understanding bytecode makes you a better programmer. Статья немного устарела, но до сих пор актуальна. Руководство пользователя BCEL содержит достойное описание основ байт-кода, поэтому я предложил бы почитать его интересующимся. Кроме того, спецификация виртуальной машины также может быть полезным источником информации, но ее нелегко читать, кроме этого отсутствует графический материал, который бывает полезным при понимании.
В целом, я думаю, что понимание того, как работает байт-код, является важным моментом в углублении своих знаний в Java-программировании, особенно для тех, кто присматривается к фреймворкам, компиляторам JVM-языков или другим утилитам.
Полёт свиньи, или Оптимизация интерпретаторов байт-кода
«No matter how hard you try, you can’t make a racehorse out of a pig. You can, however, make a faster pig» (комментарий в исходном коде Емакса)
Всем известен тот факт, что свиньи не летают. Не менее популярно мнение о том, что интерпретаторы байт-кодов как техника исполнения языков высокого уровня не поддаются ускорению без применения трудоёмкой динамической компиляции.
Во второй части серии статей об интерпретаторах байт-кодов я на примере небольшой стековой виртуальной машины ПВМ («Поросячья Виртуальная Машина») постараюсь показать, что не всё потеряно для трудолюбивых поросят с амбициями и что в рамках (в основном) стандартного C вполне возможно ускорить работу таких интерпретаторов по меньшей мере в полтора раза.
ПоросенокВМ
«ПоросёнокВМ» — заурядная стековая машина, основанная на примере из первой части серии статей. Наша свинка знает только один тип данных — 64-битное машинное слово, а все (целочисленные) вычисления производит на стеке максимальной глубиной в 256 машинных слов. Помимо стека, у этого поросёнка имеется рабочая память объёмом 65 536 машинных слов. Результат выполнения программы — одно машинное слово — можно либо поместить в регистр-результат, либо просто вывести в стандартный вывод (stdout).
Всё состояние в машине «ПоросёнокВМ» хранится в единственной структуре:
Вышеперечисленное позволяет отнести эту машину к низкоуровневым виртуальным машинам, почти все накладные расходы в которых приходятся на обслуживание главного цикла программы:
Из кода видно, что на каждый опкод поросёнок должен:
Полезная нагрузка здесь только в пятом пункте, всё остальное — накладные расходы: декодирование или извлечение из стека аргументов инструкции (пункт 4), проверка значения опкода (пункт 2), многократное возвращение в начало главного цикла и последующий труднопредсказуемый условный переход (пункт 3).
Словом, у хрюшки явно превышен рекомендованный индекс массы тела, и если мы хотим привести её в форму, то придётся со всеми этими излишествами бороться.
Свинский язык ассемблера и решето Эратосфена
Для начала определимся с правилами игры.
Писать программы для виртуальной машины прямо в C — моветон, но и создавать язык программирования — долго, поэтому мы с поросёнком решили ограничиться свинским языком ассемблера.
Программа, вычисляющая сумму чисел от 1 до 65 536, на этом ассемблере выглядит примерно так:
Не Python, конечно, но всё необходимое для поросячьего счастья тут есть: комментарии, метки, условные и безусловные переходы по ним, мнемоники для инструкций и возможность указывать непосредственные аргументы инструкций.
В комплекте с машиной «ПоросёнокВМ» идут ассемблер и дизассемблер, которые смелые духом и имеющие много свободного времени читатели могут самостоятельно опробовать в бою.
Числа суммируются очень быстро, поэтому для тестирования производительности я написал другую программу — наивную реализацию решета Эратосфена.
На самом деле поросёнок и так бегает довольно быстро (его инструкции близки к машинным), поэтому для получения внятных результатов каждый замер я буду делать для ста запусков программы.
Первая версия нашей неоптимизированной свиньи бегает примерно так:
Полсекунды! Сравнение, безусловно, нечестное, но тот же алгоритм на Python сто пробежек делает чуть медленнее:
4,5 секунды, или в девять раз медленнее. Надо отдать должное поросёнку — способности у него есть! Ну а теперь давайте посмотрим, может ли наша свинья накачать пресс.
Упражнение первое: статические суперинструкции
Первое правило быстрого кода — не делать лишней работы. Второе правило быстрого кода — не делать лишней работы никогда. Так какую лишнюю работу делает «ПоросёнокВМ»?
Наблюдение первое: профилирование нашей программы показывает, что есть последовательности инструкций, встречающиеся чаще других. Не будем сильно мучить нашу свинью и ограничимся только парами инструкций:
В машине «ПоросёнокВМ» чуть больше 20 инструкций, а для кодирования используется целый байт — 256 значений. Внесение новых инструкций не проблема. Что мы и сделаем:
Ничего сложного. Давайте посмотрим, что из этого получилось:
Ого! Кода всего-то на три новых инструкции, а выиграли мы полторы сотни миллисекунд!
Выигрыш здесь достигается благодаря тому, что наш поросёнок при выполнении таких инструкций не делает лишних движений: поток исполнения не вываливается в главный цикл, ничего дополнительно не декодируется, а аргументы инструкций не проходят лишний раз через стек.
Это называется статическими суперинструкциями, поскольку дополнительные инструкции определяются статически, то есть программистом виртуальной машины на этапе разработки. Это простая и эффективная техника, которую в той или иной форме используют все виртуальные машины языков программирования.
Главная проблема статических суперинструкций заключается в том, что без конкретной программы невозможно определить, какие именно инструкции стоит объединить. Разные программы пользуются разными последовательностями инструкций, и узнать эти последовательности можно только на этапе запуска конкретного кода.
Следующим шагом могла бы стать динамическая компиляция суперинструкций в контексте конкретной программы, то есть динамические суперинструкции (в 90-е и в начале 2000-х этот приём играл роль примитивной JIT-компиляции).
В рамках обычного С создавать инструкции на лету невозможно, и наш поросёнок совершенно справедливо не считает это честным соревнованием. К счастью, у меня для него есть пара упражнений получше.
Упражнение второе: проверка интервала значений опкодов
Следуя нашим правилам быстрого кода, ещё раз зададимся вечным вопросом: что можно не делать?
Когда мы знакомились с устройством машины «ПоросёнокВМ», я перечислял все действия, которые виртуальная машина выполняет для каждого опкода. И пункт 2 (проверка значения опкода на вхождение в допустимый интервал значений switch) вызывает больше всего подозрений.
Присмотримся к тому, как GCC компилирует конструкцию switch:
Но зачем делать проверку интервала значений на каждую инструкцию? Мы считаем, что опкод бывает либо правильный — завершающий исполнение инструкцией OP_DONE, либо неправильный — вышедший за пределы байт-кода. Хвост потока опкодов отмечен нулём, а ноль — опкод инструкции OP_ABORT, завершающей исполнение байт-кода с ошибкой.
Выходит, эта проверка вообще не нужна! И поросёнок должен уметь доносить эту мысль до компилятора. Попробуем немного поправить главный switch:
Зная, что инструкций у нас всего 26, мы накладываем битовую маску (восьмеричное значение 0x1f — это двоичное 0b11111, покрывающее интервал значений от 0 до 31) на опкод и добавляем обработчики на неиспользованные значения в интервале от 26 до 31.
Битовые инструкции — одни из самых дешёвых в архитектуре x86, и они уж точно дешевле проблемных условных переходов вроде того, который использует проверка на интервал значений. Теоретически мы должны выигрывать несколько циклов на каждой исполняемой инструкции, если компилятор поймёт наш намёк.
Кстати, способ указания интервала значений в case — не стандартный C, а расширение GCC. Но для наших целей этот код подходит, тем более что переделать его на несколько обработчиков для каждого из ненужных значений несложно.
Ещё 50 миллисекунд! Поросёнок, ты будто бы в плечах раздался.
Упражнение третье: трассы
Какие ещё упражнения могут помочь нашему поросёнку? Самую большую экономию времени мы получили благодаря суперинструкциям. А они уменьшают количество выходов в главный цикл и позволяют избавиться от соответствующих накладных расходов.
Центральный switch — главное проблемное место для любого процессора с внеочередным выполнением инструкций. Современные предсказатели ветвлений научились неплохо прогнозировать даже такие сложные непрямые переходы, но «размазывание» точек ветвлений по коду может помочь процессору быстро переходить от инструкции к инструкции.
Другая проблема — это побайтовое чтение опкодов инструкций и непосредственных аргументов из байт-кода. Физические машины оперируют 64-битным машинным словом и не очень любят, когда код оперирует меньшими значениями.
Компиляторы часто оперируют базовыми блоками, то есть последовательностями инструкций без ветвлений и меток внутри. Базовый блок начинается либо с начала программы, либо с метки, а заканчивается концом программы, условным ветвлением или прямым переходом к метке, начинающей следующий базовый блок.
У работы с базовыми блоками много преимуществ, но нашу свинью интересует именно её ключевая особенность: инструкции в пределах базового блока выполняются последовательно. Было бы здорово как-нибудь выделять эти базовые блоки и выполнять инструкции в них, не теряя времени на выход в главный цикл.
В нашем случае можно даже расширить определение базового блока до трассы. Трасса в терминах машины «ПоросёнокВМ» будет включать в себя все последовательно связанные (то есть при помощи безусловных переходов) базовые блоки.
Помимо последовательного выполнения инструкций, неплохо было бы ещё заранее декодировать непосредственные аргументы инструкций.
Звучит всё это довольно страшно и напоминает динамическую компиляцию, которую мы решили не использовать. Поросёнок даже немного засомневался в своих силах, но на практике всё оказалось не так плохо.
Давайте сначала подумаем, как можно представить входящую в трассу инструкцию:
Здесь arg — заранее декодированный аргумент инструкции, а handler — указатель на функцию, выполняющую логику инструкции.
Теперь представление каждой трассы выглядит так:
То есть трасса — это последовательность s-кодов ограниченной длины. Сам кеш трасс внутри виртуальной машины выглядит так:
Это просто массив из трасс длиной, не превышающей возможную длину байт-кода. Решение ленивое, практически для экономии памяти имеет смысл использовать хеш-таблицу.
В начале работы интерпретатора первый обработчик каждой из трасс будет сам себя компилировать:
Главный цикл интерпретатора теперь выглядит так:
Компилирующий трассу обработчик чуть сложнее, и, помимо сборки трассы, начинающейся от текущей инструкции, он делает следующее:
Нормальный обработчик инструкции:
Завершает работу каждой трассы обработчик, не делающий никаких вызовов в хвосте функции:
Всё это, конечно, сложнее, чем добавление суперинструкций, но давайте посмотрим, дало ли это нам что-нибудь:
Ура, ещё 30 миллисекунд!
Как же так? Вместо простых переходов по меткам мы делаем цепочки вызовов обработчиков инструкций, тратим время на вызовы и передачу аргументов, но наш поросёнок всё равно бегает по трассам быстрее простого switch с его метками.
Такой выигрыш в производительности трасс достигается благодаря трём факторам:
Прежде чем подвести итоги наших тренировок, мы с поросёнком решили испробовать ещё одну древнюю технику интерпретации программ — шитый код.
Упражнение четвертое: «шитый» код
Любая интересующаяся историей интерпретаторов свинья слышала про шитый код (англ. threaded code). Вариантов этого приёма множество, но все они сводятся к тому, чтобы вместо массива опкодов идти по массиву, например, указателей на функции или метки, переходя по ним непосредственно, без промежуточного опкода.
Вызовы функций — дело дорогое и особого смысла в наши дни не имеющее; большая часть других версий шитого кода нереализуема в рамках стандартного C. Даже техника, о которой речь пойдёт ниже, использует широко распространённое, но всё же нестандартное расширение C — указатели на метки.
В версии шитого кода (англ. token threaded code), которую я выбрал для достижения наших свинских целей, мы сохраняем байт-код, но перед началом интерпретации создаём таблицу, отображающую опкоды инструкций на адреса меток обработчиков инструкций:
Обратите внимание на символы && — это указатели на метки с телами инструкций, то самое нестандартное расширение GCC.
Для начала выполнения кода достаточно прыгнуть по указателю на метку, соответствующую первому опкоду программы:
Никакого цикла здесь нет и не будет, каждая из инструкций сама делает прыжок к следующему обработчику:
Отсутствие switch «размазывает» точки ветвлений по телам инструкций, что в теории должно помочь предсказателю ветвлений при внеочередном выполнении инструкций. Мы как бы встроили switch прямо в инструкции и вручную сформировали таблицу переходов.
Вот и вся техника. Поросёнку она понравилась своей простотой. Посмотрим, что получается на практике:
Упс! Это самая медленная из всех наших техник! Что же случилось? Выполним те же тесты, выключив все оптимизации GCC:
Здесь шитый код показывает себя лучше.
Тут играют роль три фактора:
По старой памяти эту технику ещё используют в коде, например, интерпретатора Python VM, но, честно говоря, в наши дни это уже архаизм.
Давайте, наконец, подведём итоги и оценим успехи, которых добилась наша свинья.
Разбор поросячьих полетов
Не уверен, что это можно назвать полётом, но, давайте признаем, наш поросёнок прошёл большой путь от 550 миллисекунд на сто пробежек по «решету» до финальных 370 миллисекунд. Мы использовали разные техники: суперинструкции, избавление от проверки интервалов значений, сложную механику трасс и, наконец, даже шитый код. При этом мы, в общем-то, действовали в рамках вещей, реализованных во всех популярных компиляторах C. Ускорение в полтора раза, как мне кажется, это неплохой результат, и поросёнок заслужил лишнюю порцию отрубей в корыте.
Одно из неявных условий, которое мы со свиньёй себе поставили, — сохранение стековой архитектуры машины «ПоросёнокВМ». Переход к регистровой архитектуре, как правило, уменьшает количество необходимых для логики программ инструкций и, соответственно, может помочь избавиться от лишних выходов в диспетчер инструкций. Думаю, ещё 10—20% времени на этом можно было бы срезать.
Основное же наше условие — отсутствие динамической компиляции — тоже не закон природы. Накачать свинью стероидами в виде JIT-компиляции в наши дни очень даже несложно: в библиотеках вроде GNU Lightning или LibJIT вся грязная работа уже сделана. Но время на разработку и общий объём кода даже с использованием библиотек здорово увеличиваются.
Существуют, конечно, и другие приёмы, до которых у нашего поросёнка не дошли копытца. Но пределов совершенству нет, и наше свинское путешествие — вторая часть серии статей про интерпрепретаторы байт-кодов — всё же должно где-то закончиться. Если читателям придут в голову интересные способы разогнать свинью, мы с поросёнком будем рады их испробовать.
PS Отдельная благодарность моей сестре, Ренате Казановой, за эскизы иллюстраций, и нашему иллюстратору, Владимиру Шопотову (https://www.instagram.com/vovazomb/), за финальные рисунки.
PPS Оригинальный поросенок не очень разговорчив в целом, и понимает только примитивный ассемблер. Но true-grue каким-то волшебным образом за несколько часов сделал для него небольшой язык — PigletC. Теперь можно хрюкать без ограничений!
PPPS Читатель iliazeus предложил еще одну оптимизацию: кеширование вершины стека в отдельной переменной. Удивительным образом это изменение делает шитый код самым быстрым вариантом из всех; для него решето Эратосфена работает в два раза быстрее оригинальной наивной версии интерпретатора. Всех любопытствующих прошу смотреть в код.