Еще забавнее то, что процедуры без непосредственных операндов могут заглянуть на один байткод вперед. Например, мы могли бы иметь макрос GAS-ассемблера (скажем, “TRICKY”), который:
- смотрит на следующий байткод, доступный ему в immed32,
- проверяет, что это не условный переход Je,
- переписывает машинный своей процедуры, а вслед за ней - и его процедуры в свободную память (т.к. любая процедура оканчивается на DISPATCH - это несложно)
- расширяет таблицу routines чтобы включить свежесозданную процедуру и сформировать новый опкод,
- заменяет свой текущий опкод по адресу prog_mem[pc] на новый опкод,
- заменяет опкод по адресу prog_mem[pc+1] (следующий за собой) на Nop (или Jump вперед)!
Ой, я же не собирался писать компилирующий интерпретатор байткода.. Но, вообще-то, это замечательный способ делать капсулы двоичной трансляции “Just-In-Time” - он размазан по времени и может применяться по мере достижения счетчиком вызова процедуры какого-то порогового значения. (Если вы не помните, что такое капсулы - пробегите глазами базовую статью Atakua). А сама техника называется “динамические суперинструкции”, о них есть статья: Полёт свиньи, или Оптимизация интерпретаторов байт-кода. Суперинструкции уменьшают накладные расходны на выполнение FETCH, DECODE и DISPATCH. Каждая такая суперинструкция хочет дорасти до скомпилированного “базового блока”, то есть последовательности машинных инструкций без ветвлений и меток внутри - с одним входом и одним выходом - своего рода “мультикапсулы”.
Есть еще несколько идей, стоящих рассмотрения, но выходящих за рамки этой статьи:
- DECODE может подсчитывать, сколько раз приходится проходить через каждый адрес. Это тратит один инкремент ячейки памяти, и при таких малых затратах позволяет эффективно находить “горячий код”.
- FETCH может анализировать целевые адреса для инструкций прыжков и таким способом эффективно находить циклы. Часто выполняющийся цикл - первый кандидат на капсулирование и разные оптимизации, вроде unroll.
- Мы можем теггировать опкоды байткода, чтобы добавлять нему дополнительные команды для интерпретатора. Например, интерпретатор может по команде формировать суперинструкцию для участка байткода. Теги могут распространяться по коду (propagation, bytecode walker), не меняя код и не мешая ему исполняться. Можно даже привязать скорость tag propagation к тому, насколько часто интерпретатор посещает адрес, где лежит опкод. Это метод может регулировать размер суперинструкций.
- Если мы делаем форт-компилятор, теги байткода можно ставить статически, при формировании байткода, потому что на уровне компилятора видны границы циклов даже без эвристического анализа. Тогда runtime-компилятор сможет использовать статические теги чтобы определить границы циклов, а динамические - для определения, насколько код “горячий”.
- Ввод-вывод может выполняться асинхронно, через очереди, чтение из которых и собственно вывод на экран происходит в отдельном потоке.
- Чтобы не терять производительность, формирование суперинструкций можно вынести в отдельный поток. Тогда понадобится механизм прерывания выполнения байткода. Так можно делать модификацию байткода во время выполнения: например, подменяя обычные инструкции на суперинструкции. Это могут быть как синхронные прерывания, вроде INT3, размещенные в точках переключения инструкций, так и внешние, о которых байткод ничего не знает. Синхронные прерывания также можно использовать для написания отладчика байткода.
- Также интересно реализовать механизм деоптимизации при самомодификации байткода и для отладочных целей, чтобы разворачивать суперинструкции обратно в байткод.
- Записывать трассы исполнения
Так, стоп, похоже эта статья хочет стать двумя, тремя, десятью… Впрочем, в