В этой и последующих статьях я хотел бы рассказать о байткоде YARV, виртуальной машины, используемой в Ruby MRI1 1.9.
Для начала немного истории. Самая первая реализация Ruby, которая в итоге превратилась в Ruby 1.8, имела очень неэффективный интерпретатор: при загрузке код превращался в абстрактное синтаксическое дерево, которое целиком и хранилось в памяти, а исполнение кода представляло из себя тривиальный обход этого дерева. Если даже закрыть глаза на то, что обход огромного дерева (подумайте о Rails, AST которого занимало порядка десятка мегабайт2) по ссылкам в памяти вещь достаточно медленная, ведь процессор не сможет адекватно кешировать код, то в любом случае такая реализация не давала возможности для хоть каких-нибудь оптимизаций. Учитывая еще и то, что из-за чрезвычайно гибкой объектно-ориентированной системы, в которой можно было переопределить методы любого объекта, включая встроенный класс Fixnum, арифметические вычисления производились путем вызова методов на объектах (да, 5 + 3 вызывало метод "+" объекта 5 с созданием стекового фрейма), Ruby 1.8 получился одним из самых медленных среди распространенных интерпретируемых языков программирования.
YARV (Yet Another Ruby VM) — стековая виртуальная машина, разработанная Sasada Koichi и затем интегрированная в основное дерево, исправила если не все, то многие из этих недостатков. Код теперь транслируется в компактное представление, оптимизируется3 и выполняется существенно быстрее, чем раньше.
Здесь, впрочем, есть одно важное отличие от других виртуальных машин. Байткод, который порождает YARV, можно сохранить, но нельзя загрузить — в распространяемой версии загрузчик байткода отключен (хотя в исходном коде он есть и его можно включить, если это нужно). Официальная причина — отсутствие верификатора, но, как мне кажется, истина заключается в том, что этот байткод считается внутренним форматом, в который можно в любой момент внести изменения, не задумываясь о совместимости, и такую ситуацию стремятся сохранить.
В результате, несмотря на то, что доступ к байткоду совершенно незаменим при анализе производительности или разработке альтернативных интерпретаторов, какая-либо документация по нему отсутствует как класс. Лучшее из того, что можно найти — это сайты, подобные YARV Instructions, представляющие из себя просто распарсенный файл определения виртуальной машины из исходного кода Ruby. (Смысл наличия части полей в заголовке дампа байткода я понял из названий переменных в посте в блоге одного японца.)
Я хотел бы отчасти исправить подобную ситуацию. В этой и следующих статьях я расскажу, что именно мне удалось понять в устройстве байткода Ruby и как я это применил на практике. Сразу скажу, что некоторые особенности мне понять частично или полностью не удалось; в таких случаях я буду отмечать это отдельно. Если же подобной фразы нет, это означает, что полученную информацию мне удалось проверить на практике и все работает так, как и должно быть.
Приступим, собственно, к байткоду. В Ruby4 есть системный класс RubyVM::InstructionSequence, который позволяет скомпилировать произвольный текст в байткод (насколько мне известно, получить байткод загруженной программы невозможно). В простейшем случае достаточно воспользоваться методом InstructionSequence.compile, который возвращает объект этого класса, и метода InstructionSequence#to_a, который возвращает дамп байткода.
Читатели, знающие Ruby, уже заметили, что дамп должен быть массивом, ведь метод #to_a, согласно принципу Convention over Configuration, должен преобразовывать объект в массив.
Здесь необходимо небольшое отступление. В каноническом варианте реализации байткод, как подсказывает его название — это последовательность байтов, и где-то глубоко внутри интерпретатора именно так он и выглядит. Однако то его представление, которое можно получить стандартными средствами, выглядит как обычный объект Ruby — а именно, дерево, состоящее из вложенных массивов. В нем встречается лишь минимальное подмножество стандартных типов: Array, String, Symbol, Fixnum, Hash (только в заголовке), а так же nil, true и false. Это очень удобно (и в стиле Ruby): можно не заниматься разбором двоичных данных, а сразу работать с читаемым их представлением, не думая о магических константах, номерах опкодов и несовместимых изменениях в следующих версиях транслятора.
Итак, получим дамп какой-нибудь простенькой программы:
ruby-1.9.2-p136 :001 > seq = RubyVM::InstructionSequence.compile(%{puts "Hello, YARV!"})
=> <RubyVM::InstructionSequence:<compiled>@<compiled>>
ruby-1.9.2-p136 :002 > seq.to_a
=> ["YARVInstructionSequence/SimpleDataFormat", 1, 2, 1, {:arg_size=>0, :local_size=>1, :stack_max=>2}, "<compiled>", "<compiled>", nil, 1, :top, [], 0, [], [1, [:trace, 1], [:putnil], [:putstring, "Hello, YARV!"], [:send, :puts, 1, nil, 8, 0], [:leave]]]
Дамп состоит из двух частей: заголовка и собственно кода. Рассмотрим поля заголовка.
"YARVInstructionSequence/SimpleDataFormat", 1, 2, 1, {:arg_size=>0, :local_size=>1, :stack_max=>2}, "<compiled>", "<compiled>", nil, 1, :top, [], 0, []
Первые четыре поля в сущности представляют из себя магическое значение, идентифицирующее байткод, но последние три поля это еще и версия в формате major, minor, format. (Это те самые поля, которые я обнаружил в японском блоге. И нет, это далеко не очевидно.)
"YARVInstructionSequence/SimpleDataFormat", 1, 2, 1, {:arg_size=>0, :local_size=>1, :stack_max=>2}, "<compiled>", "<compiled>", nil, 1, :top, [], 0, []
Пятое поле — хеш, содержащий несколько параметров стекового фрейма, который будет создан для этого участка кода. Назначение :arg_size и :stack_max, я думаю, очевидно.
Параметр :local_size, по идее, должен содержать количество локальных переменных, но на самом деле он всегда больше на 1. Эта единица наглухо вбита в код (compile.c, 342); сначала я думал, что в ней хранится значение self, но оно (что, если вдуматься, логичнее) находится в стековом фрейме.
"YARVInstructionSequence/SimpleDataFormat", 1, 2, 1, {:arg_size=>0, :local_size=>1, :stack_max=>2}, "<compiled>", "<compiled>", nil, 1, :top, [], 0, []
Следующие четыре поля содержат название метода (или псевдоназвание, например «block in main»); название файла, в котором он определен, в том виде, как его загрузили (например, require '../something' порождает блок, в котором это поле содержит '../something'); полный путь к файлу (вероятно, для отладчика) и строка, на которой начинается определение соответствующего блока кода.
"YARVInstructionSequence/SimpleDataFormat", 1, 2, 1, {:arg_size=>0, :local_size=>1, :stack_max=>2}, "<compiled>", "<compiled>", nil, 1, :top, [], 0, []
Следующее поле содержит тип блока кода. Мне встречались значения :top (toplevel; «просто» код, который не вложен ни в метод, ни в класс), :block, :method и :class.
В исходном коде Ruby (vm_core.h, 552) определены следующие значения: top, method, class, block, finish, cfunc, proc, lambda, ifunc и eval. Бóльшая часть из них не встречается в байткоде и, вероятно, присваивается динамически; так, блок с типом ifunc создается при yield в тех случаях, когда переданный блок является C-функцией (vm_insnhelper.c, 721). Назначение прочих (кроме cfunc) мне в данный момент не ясно, могу лишь написать, что блоки типа lambda, судя по коду, совершенно однозначно порождаются при компиляции AST, но в то же время они мне ни разу не встречались. Предположительно, это относится к оптимизации (которой я пока не занимался вообще).
"YARVInstructionSequence/SimpleDataFormat", 1, 2, 1, {:arg_size=>0, :local_size=>1, :stack_max=>2}, "<compiled>", "<compiled>", nil, 1, :top, [], 0, []
В следующих двух полях содержится список локальных переменных (массив символов, что-то вроде
[:local1, :local2]
и количество аргументов. Вместо количества в некоторых случаях (например, при наличии аргументов со значениями по умолчанию, или аргументов вида *splat или &block) там может быть массив, формат которого до конца мне не известен; я рассмотрю его, когда буду писать про вызов функций.Список локальных переменных во время выполнения нужен, вероятно, для того, чтобы можно было реализовать класс Binding, без которого, скажем, невозможно сделать REPL.
"YARVInstructionSequence/SimpleDataFormat", 1, 2, 1, {:arg_size=>0, :local_size=>1, :stack_max=>2}, "<compiled>", "<compiled>", nil, 1, :top, [], 0, []
Предпоследнее поле — это catch table, до сих пор остающаяся для меня полнейшей загадкой. В этой мистической структуре есть как собственно конструкции, связанные с исключениями (catch и ensure), так и записи, каким-то образом относящиеся к реализации ключевых слов next, redo, retry и break, причем первые две, несмотря на наличие записей в catch table, вообще никак ее не используют.
[
1,
[:trace, 1],
[:putnil],
[:putstring, "Hello, YARV!"],
[:send, :puts, 1, nil, 8, 0],
[:leave]
]
И, наконец, последнее поле — это собственно код.
Код представляет из себя массив с последовательностью инструкций, перемежаемых номерами строк и метками; если элемент — число, то это номер строки, если символ вида :label_01, то это метка, на которую может происходить переход, иначе же это будет массив, представляющий из себя инструкцию.
[:putstring, "Hello, YARV!"]
Первый элемент инструкции — всегда символ, содержащий название инструкции, остальные элементы — очевидно, ее аргументы.
Общие принципы функционирования виртуальной машины и подробное описание инструкций будет в следующей части.
1 Matz Reference Implementation
2 Об этом можно почитать, например, здесь.
3 В настройках транслятора есть около десятка оптимизаций, включая peephole, tailcall, а так же различные кеши и специализированные варианты инструкций.
4 Здесь и далее Ruby означает Ruby MRI 1.9.x.
P.S. И даже под страхом смерти я не напишу ни слова о Bra… вы поняли, о чем я говорю.