Pull to refresh

Ruby Inside. Байткод YARV (I)

Reading time 6 min
Views 3.3K

В этой и последующих статьях я хотел бы рассказать о байткоде 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… вы поняли, о чем я говорю.
Tags:
Hubs:
+38
Comments 7
Comments Comments 7

Articles