Часть 2. Иллюзорный автомобиль
Jan. 11th, 2010 12:27 amЧуть менее, чем все виртуальные машины, которые я встречал и делал, были стековыми или регистровыми. В первом случае у нас есть некий стек значений, в него можно складывать всякие константы, а разные операции берут из него значения, проводят над ними какие-то действия, и результат кладут вместо взятых. При этом большинство команд в байткоде не имеют аргументов, т.к. их аргументы уже лежат на стеке, будучи положены туда другими командами. Соответственно, даже простые действия представляются целой серией операций (положить в стек один аргумент, положить другой, совершить действие...), и все время туда-сюда изменяется stack pointer, в простейшем случае - индекс в массиве. В случае регистровых ВМ у нас есть массив регистров, а у команд есть аргументы, указывающие из каких регистров брать исходные значения и куда сохранять результат операции. Количество операций, необходимых для совершения тех же действий, у регистровой ВМ меньше, но если мы хотим иметь вызовы функций, особенно рекурсивных, то возникают отдельные сложности - нужно или сохранять нужные регистры на стеке, или иметь целый стек регистровых массивов.
Виртуальная машина, которая использовалась у меня до этого момента, и в код которой компилился тот DSEL на Руби, была регистровой, причем вообще без стека. Все функции там инлайнились, а рекурсивные функции не поддерживались. Выделением памяти она тоже не занималась - все необходимые буферы должны были быть выделены вызывающей программой и переданы через регистры. Такие ограничения создавали ряд неудобств, поэтому было решено, что в LeoVM нужен какой-то стек и какое-то управление памятью. В стеке будут аргументы функций. Если из функции А вызывается Б, то локальные переменные А должны пережить этот вызов, значит их тоже стоит хранить в стеке. Что осталось? Временные значения при вычислении сложных выражений. Стек у нас резиновый, почему бы не поместить их туда же. В итоге отдельный набор регистров уже не нужен. Но хочется сохранить хорошую скорость регистровой ВМ, поэтому будем к значениям на стеке относиться как к регистрам: пусть команды берут исходные значения не с верхушки стека, а с произвольных позиций во фрейме, и результат пусть пишут в произвольное место. Каждое значение на стеке будет адресоваться смещением относительно frame pointer'a - специального регистра, хранящего абсолютный адрес некоторого места в стеке. Количество локальных переменных в каждой функции компилятору известно. При компиляции сложных выражений смещения для временных значений тоже можно вычислить заранее. Т.е. тот процесс распределения адресов, который в стековой ВМ происходит динамически путем изменения SP, можно один раз проделать статически при компиляции. При вызове функций мы сохраняем стековую семантику: аргументы кладутся на верхушку стека, а после отработки вызванной функции вместо них на стеке должен быть результат. В LeoVM никакого Stack Pointer'a нет, и "верхушка стека" определяется статически как минимальное смещение от FP, которое больше всех смещений переменных и временных значений. Количество аргументов и возвращаемых значений тоже компилятору известно, поэтому он просто вычисляет "верхушку стека", кладет аргументы туда, и при вызове функции увеличивает Frame Pointer на величину ("верхушка стека" + место для аргументов и результата). Соответственно, команда Call имеет два аргумента - адрес кода функции и величину, на которую нужно увеличить FP (команд прямого изменения FP нету, он меняется только командами Call и Ret). Вызванная функция знает, что аргументы надо искать слева от FP, туда же нужно положить результат, а справа от FP - свободное место для локальных переменных и пр.

Команд в LeoVM изначально было всего 15: арифметика, копирование интов и байтов, вызов/возврат, выделение памяти, условные и безусловный переходы. Арифметические команды трехадресные - имеют три аргумента: два источника и результат. У других команд может быть меньше источников, или нет результата, зато есть адрес кода для перехода или вызова. Каждый источник и результат задается числом, а у команд есть модификаторы, которые говорят как это число понимать - как регистр с заданным смещением от FP, как значение, на которое такой регистр указывает, или как просто число. Команды условного перехода имеют два аргумента-источника и адрес. Они сравнивают аргументы, и если условие выполнено, то управление переходит по указанному адресу.
Про память.
В программах, которые будут писаться для LeoVM, динамическое выделение памяти нужно довольно редко и в основном для выделения временных краткоживущих буферов для расшифрованных строк и посчитанных матриц. Делать ручное освобождение или сложный механизм отслеживания мусора не хотелось, поэтому была реализована очень простая схема. В набор команд вошла команда New с аргументом-назначением, получающим адрес выделенной памяти, и аргументом-источником, задающим требуемый размер. У ВМ есть большой заранее выделенный кусок памяти, от которого просто отщипывается очередная часть, и указатель на свободную зону (HP - Heap Pointer) смещается. Когда командой Call вызывается какая-нибудь функция, этот указатель, вместе с Frame Pointer и Instruction Pointer сохраняется в отдельном стеке контекстов (в стек данных они не пишутся, чтобы нельзя было испортить), а при возврате по команде Ret они все восстанавливаются, таким образом, вся выделенная внутри функции динамическая память автоматически освобождается одним махом. При этом компилятор гарантирует, что указатели на нее не будут переданы из функции наверх. Спросите, как же работал map из первого примера? Об этом позже, но краткий ответ: без Call'ов.
Оптимизация.
Когда работающий вариант ВМ был получен и проверен, я стал снизу-вверх строить компилятор, постепенно повышая уровень конструкций, о чем будет рассказано в других частях. Когда компилятор был готов, пришло время заняться оптимизацией. Для этого команды ВМ были разделены на комбинации более мелких команд, реализующих элементарные действия. Например, команда ADD Dest Src1 Src2 была представлена последовательностью из четырех команды: LOAD Src2, LOAD Dest, LOAD Src1, ADD. Получилась новая МикроВМ с той же семантикой, но другим набором инструкций, более атомарным. Компилятор был модифицирован на выдачу кода этой МикроВМ. После этого в МикроВМ была добавлена запись трасс: во время исполнения программы каждая исполняемая команда записывалась в трассу в виде одного символа. На более-менее типичной программе была получена трасса - файл размером около 100 мегабайт. После этого был написан специальный компрессор: он считал число вхождений каждой пары подряд идущих символов, брал самую часто встречающуюся пару и заменял ее на новый символ. Т.е. определялось, какие действия чаще всего идут подряд, чтобы можно было их объединить в одну команду. После каждой такой итерации размер трассы сокращался, и после примерно 20 итераций он сократился в 10 раз. Дальше полученные супер-команды были проанализированы, и стало ясно, какие комбинации стоит сделать отдельными командами ВМ. Например, оказалось, что в командах условного перехода чаще всего сравниваются регистры. А из арифметики чаще всего используется сложение, причем либо с только регистровыми операндами, либо с регистрами и константой. И еще, что тоже логично, часто встречается увеличение регистра на 1 или 4 (размер инта). В итоге к первоначальному набору из 15 команд были добавлены еще 7, являющиеся частными случаями предыдущих команд, когда жестко заданы модификаторы и/или значения. Так, например, появились команды INC и INC4. Частота появления этих новоиспеченных инструкций весьма велика, а поскольку при их исполнении не нужно разбирать модификаторы аргументов и ветвиться по ним, то скорость исполнения заметно возрастает. Такая оптимизация (плюс перенос разбора модификаторов из главного цикла внутрь веток отдельных команд) увеличила скорость работы в 2 раза по сравнению с оригинальной LeoVM.
Дальше уже в новую ВМ была добавлена запись трассы и был проведен аналогичный анализ. Он показал, что очень часто за безусловным переходом сразу идет сравнение и условный переход. Это было вызвано устройством циклов, которые компилировались так:
Т.е. из конца цикла делался прыжок на условие, вот и получалась такая частая комбинация. Но ее можно сократить, если компилировать циклы иначе:
Так у нас цикл на одну инструкцию короче - нет безусловного перехода. Эта оптимизация дала около 5%, т.к. безусловный переход был довольно простой командой, и много времени на него не уходило.
Сама ВМ сделана как простой цикл
Дальше: Часть 3. Первые шаги
Виртуальная машина, которая использовалась у меня до этого момента, и в код которой компилился тот DSEL на Руби, была регистровой, причем вообще без стека. Все функции там инлайнились, а рекурсивные функции не поддерживались. Выделением памяти она тоже не занималась - все необходимые буферы должны были быть выделены вызывающей программой и переданы через регистры. Такие ограничения создавали ряд неудобств, поэтому было решено, что в LeoVM нужен какой-то стек и какое-то управление памятью. В стеке будут аргументы функций. Если из функции А вызывается Б, то локальные переменные А должны пережить этот вызов, значит их тоже стоит хранить в стеке. Что осталось? Временные значения при вычислении сложных выражений. Стек у нас резиновый, почему бы не поместить их туда же. В итоге отдельный набор регистров уже не нужен. Но хочется сохранить хорошую скорость регистровой ВМ, поэтому будем к значениям на стеке относиться как к регистрам: пусть команды берут исходные значения не с верхушки стека, а с произвольных позиций во фрейме, и результат пусть пишут в произвольное место. Каждое значение на стеке будет адресоваться смещением относительно frame pointer'a - специального регистра, хранящего абсолютный адрес некоторого места в стеке. Количество локальных переменных в каждой функции компилятору известно. При компиляции сложных выражений смещения для временных значений тоже можно вычислить заранее. Т.е. тот процесс распределения адресов, который в стековой ВМ происходит динамически путем изменения SP, можно один раз проделать статически при компиляции. При вызове функций мы сохраняем стековую семантику: аргументы кладутся на верхушку стека, а после отработки вызванной функции вместо них на стеке должен быть результат. В LeoVM никакого Stack Pointer'a нет, и "верхушка стека" определяется статически как минимальное смещение от FP, которое больше всех смещений переменных и временных значений. Количество аргументов и возвращаемых значений тоже компилятору известно, поэтому он просто вычисляет "верхушку стека", кладет аргументы туда, и при вызове функции увеличивает Frame Pointer на величину ("верхушка стека" + место для аргументов и результата). Соответственно, команда Call имеет два аргумента - адрес кода функции и величину, на которую нужно увеличить FP (команд прямого изменения FP нету, он меняется только командами Call и Ret). Вызванная функция знает, что аргументы надо искать слева от FP, туда же нужно положить результат, а справа от FP - свободное место для локальных переменных и пр.

Команд в LeoVM изначально было всего 15: арифметика, копирование интов и байтов, вызов/возврат, выделение памяти, условные и безусловный переходы. Арифметические команды трехадресные - имеют три аргумента: два источника и результат. У других команд может быть меньше источников, или нет результата, зато есть адрес кода для перехода или вызова. Каждый источник и результат задается числом, а у команд есть модификаторы, которые говорят как это число понимать - как регистр с заданным смещением от FP, как значение, на которое такой регистр указывает, или как просто число. Команды условного перехода имеют два аргумента-источника и адрес. Они сравнивают аргументы, и если условие выполнено, то управление переходит по указанному адресу.
Про память.
В программах, которые будут писаться для LeoVM, динамическое выделение памяти нужно довольно редко и в основном для выделения временных краткоживущих буферов для расшифрованных строк и посчитанных матриц. Делать ручное освобождение или сложный механизм отслеживания мусора не хотелось, поэтому была реализована очень простая схема. В набор команд вошла команда New с аргументом-назначением, получающим адрес выделенной памяти, и аргументом-источником, задающим требуемый размер. У ВМ есть большой заранее выделенный кусок памяти, от которого просто отщипывается очередная часть, и указатель на свободную зону (HP - Heap Pointer) смещается. Когда командой Call вызывается какая-нибудь функция, этот указатель, вместе с Frame Pointer и Instruction Pointer сохраняется в отдельном стеке контекстов (в стек данных они не пишутся, чтобы нельзя было испортить), а при возврате по команде Ret они все восстанавливаются, таким образом, вся выделенная внутри функции динамическая память автоматически освобождается одним махом. При этом компилятор гарантирует, что указатели на нее не будут переданы из функции наверх. Спросите, как же работал map из первого примера? Об этом позже, но краткий ответ: без Call'ов.
Оптимизация.
Когда работающий вариант ВМ был получен и проверен, я стал снизу-вверх строить компилятор, постепенно повышая уровень конструкций, о чем будет рассказано в других частях. Когда компилятор был готов, пришло время заняться оптимизацией. Для этого команды ВМ были разделены на комбинации более мелких команд, реализующих элементарные действия. Например, команда ADD Dest Src1 Src2 была представлена последовательностью из четырех команды: LOAD Src2, LOAD Dest, LOAD Src1, ADD. Получилась новая МикроВМ с той же семантикой, но другим набором инструкций, более атомарным. Компилятор был модифицирован на выдачу кода этой МикроВМ. После этого в МикроВМ была добавлена запись трасс: во время исполнения программы каждая исполняемая команда записывалась в трассу в виде одного символа. На более-менее типичной программе была получена трасса - файл размером около 100 мегабайт. После этого был написан специальный компрессор: он считал число вхождений каждой пары подряд идущих символов, брал самую часто встречающуюся пару и заменял ее на новый символ. Т.е. определялось, какие действия чаще всего идут подряд, чтобы можно было их объединить в одну команду. После каждой такой итерации размер трассы сокращался, и после примерно 20 итераций он сократился в 10 раз. Дальше полученные супер-команды были проанализированы, и стало ясно, какие комбинации стоит сделать отдельными командами ВМ. Например, оказалось, что в командах условного перехода чаще всего сравниваются регистры. А из арифметики чаще всего используется сложение, причем либо с только регистровыми операндами, либо с регистрами и константой. И еще, что тоже логично, часто встречается увеличение регистра на 1 или 4 (размер инта). В итоге к первоначальному набору из 15 команд были добавлены еще 7, являющиеся частными случаями предыдущих команд, когда жестко заданы модификаторы и/или значения. Так, например, появились команды INC и INC4. Частота появления этих новоиспеченных инструкций весьма велика, а поскольку при их исполнении не нужно разбирать модификаторы аргументов и ветвиться по ним, то скорость исполнения заметно возрастает. Такая оптимизация (плюс перенос разбора модификаторов из главного цикла внутрь веток отдельных команд) увеличила скорость работы в 2 раза по сравнению с оригинальной LeoVM.
Дальше уже в новую ВМ была добавлена запись трассы и был проведен аналогичный анализ. Он показал, что очень часто за безусловным переходом сразу идет сравнение и условный переход. Это было вызвано устройством циклов, которые компилировались так:
while (condition) { body } превращался в
Label:
if (condition) {
body;
goto Label;
}Т.е. из конца цикла делался прыжок на условие, вот и получалась такая частая комбинация. Но ее можно сократить, если компилировать циклы иначе:
goto Cond_Label; Body_Label: body; Cond_Label: if (condition) goto Body_Label;
Так у нас цикл на одну инструкцию короче - нет безусловного перехода. Эта оптимизация дала около 5%, т.к. безусловный переход был довольно простой командой, и много времени на него не уходило.
Сама ВМ сделана как простой цикл
while(ip < codelen) { switch(code[ip] & CMD_MASK) { ... т.е. без всякого шитого кода и computed gotos. Что занятно, интеловский компилятор 7.1 ее компилирует в менее эффективный код, чем MSVC6.Дальше: Часть 3. Первые шаги
no subject
Date: 2010-01-10 05:37 pm (UTC)т.е. сделать в одной функции массив и сделать лямбду, которая ссылается на этот массив, а потом вернуть эту лямбду из функции нельзя?
no subject
Date: 2010-01-10 05:53 pm (UTC)no subject
Date: 2010-01-10 10:23 pm (UTC)> на этот массив, а потом вернуть эту лямбду из функции нельзя?
Лучше короче спросить: "upwards funarg problem не решена?"
Вообще, компилеростроение дело неблагодарное, т.к. что-то новое придумать крайне сложно. В частности, стековые машины как правило, допускают произвольную адресацию вглубь. Вот хотя бы JVM:, стековая часть PABC-машины, используемой в Клине, также содержит инструкции чтения и записи стеков на произвольной глубине (Плазмеер-Экелен, разделы 10.2.5, 10.2.6).
no subject
Date: 2010-01-11 03:18 am (UTC)вы чего-то попутали.
в JVM локальные переменные и стэк операндов --- это разные вещи.
no subject
Date: 2010-01-11 05:49 am (UTC)---
При этом большинство команд в байткоде не имеют аргументов, т.к. их аргументы уже лежат на стеке, будучи положены туда другими командами. Соответственно, даже простые действия представляются целой серией операций (положить в стек один аргумент, положить другой, совершить действие...)
---
Ниже thedeemon исправился и уточнил, в чём он видит новизну своего решения.
no subject
Date: 2010-01-11 07:14 am (UTC)JVM не допускает никакой "произвольной адресации вглубь"
no subject
Date: 2010-01-12 10:31 am (UTC)no subject
Date: 2010-01-12 10:37 am (UTC)причем тут вообще верификатор? откуда взялось это "нужно"?
факт №1: стэк и локальные переменные в JVM разделены.
факт №2: адресация по стеку в JVM не поддерживается.
dixi.
no subject
Date: 2010-01-12 10:45 am (UTC)С фактами 1-2 я как бы не спорю. См. также мой комментарий ниже о том, что возможность более глубокой адресации, чем текущие локальные переменные, вряд ли будет востребована кодогенератором.
no subject
Date: 2010-01-12 10:49 am (UTC)У Java совершенно другая архитектура комманд - практически постоянно используется стэк и ничего серьезного не сделать без этого. Даже из функции значения не вернуть без стэка.
no subject
Date: 2010-01-11 07:40 am (UTC)The x87 family does not use a directly addressable register set such as the main registers of the x86 processors; instead the x87 registers form a 8-level deep stack structure ranging from ST(0) to ST(7). The x87 instructions typically operate by pushing, calculating, and popping values on this stack with monadic operations (FSQRT, FPTAN etc) implicitly addressing the topmost ST(0) and dyadic operations (FADD, FMUL, FCOM, etc) implicitly addressing ST(0) and ST(1).
JVM:
The computational model of Java bytecode is that of a stack-oriented programming language. For example, assembly code for an x86 processor might look like this:
add eax, edx
mov ecx, eax
This code would add two values and move the result to a different location. Similar disassembled bytecode might look like this:
0 iload_1
1 iload_2
2 iadd
3 istore_3
CLR:
The Common Intermediate Language is object-oriented and stack-based. That means that data is pushed on a stack instead of pulled from registers like in most CPU architectures.
In x86 it might look like this:
add eax, edx
mov ecx, eax
The corresponding code in IL can be rendered as this:
ldloc.0
ldloc.1
add
stloc.0 // a = a + b or a += b
Squeak (Smalltalk):
20 <70> self
21 <10> pushTemp: 0
22 <77> pushConstant: 2
23 send: -
24 send: fib:
25 send: +
26 <7C> returnTop
Python bytecode:
21 LOAD_GLOBAL 1 (fib)
24 LOAD_FAST 0 (n)
27 LOAD_CONST 2 (1)
30 BINARY_SUBTRACT
31 CALL_FUNCTION 1
Elisp:
12 constant fib
13 varref n
14 constant 2
15 diff
16 call 1
17 plus
18 return
Yarv (Ruby 1.9), SECD, Forth - аргументы и результат на стеке.
Так что неверный пассаж вполне верный.
no subject
Date: 2010-01-12 10:48 am (UTC)> аргументы и результат на стеке
Это само по себе не является признаком отсутствия произвольной адресации. В PABC например "аргументы и результат на стеке", но есть произвольное чтение-запись вглубь.
no subject
Date: 2010-01-12 11:56 am (UTC)no subject
Date: 2010-01-11 04:42 am (UTC)no subject
Date: 2010-01-11 05:55 am (UTC)SP нет, но есть FP. В чем отличия? Чем подход отличается от JVM Local Variables Array? В JVM внутри функции SP не плавает - и обращения к переменным выполняются по их индексу.
no subject
Date: 2010-01-11 07:15 am (UTC)да выполняются... но для выполнения над ними сколь либо сложных операций их надо перекладывать в стэк, а результаты складывать обратно в локальные переменные.
no subject
Date: 2010-01-12 10:29 am (UTC)вы троллите что ли?
Date: 2010-01-12 10:33 am (UTC)Re: вы троллите что ли?
Date: 2010-01-12 10:51 am (UTC)Т.е., новизна решения thedeemon в том, что традиционно произвольная адресация поддерживается только для некоторых наиболее частых примитивных операций, а основная масса работы выполняется на стеке, а thedeemon подошел к проблеме глобально, отказавшись от стека вообще и сделав все команды трехадресными (что скорее всего ускоряет, но и утолщает код)?
no subject
Date: 2010-01-12 10:58 am (UTC)Новизна подхода как такового мне не ясна. Регистровые VM существуют давно. Вопрос о том, допускает ли какая-нибудь из существующих/существовавших машин произвольную адресацию в глубь для меня открыт --- для его разрешения требуется копание в истории (у меня где-то была статья по истории VM, но сейчас уже потерялась).
Да это не суть и важно, наверное... Главное, что человек поделился своим весьма интересным опытом.
no subject
Date: 2010-01-11 07:43 am (UTC)Я так понимаю, они сначала a и b положатся на стек операндов (меняя SP), потом безаргументный add их там сложит (меняя SP) потом верхушку стека запишут в а.
У меня же это одна команда, типа ADD|RRR, 0, 1, 2.
no subject
Date: 2010-01-11 07:44 am (UTC)no subject
Date: 2010-01-12 10:39 am (UTC)no subject
Date: 2010-01-13 08:03 am (UTC)no subject
Date: 2010-01-10 09:41 pm (UTC)> в менее эффективный код, чем MSVC6.
Насколько менее эффективный?
no subject
Date: 2010-01-11 04:38 am (UTC)Intel - 2.26 s
no subject
Date: 2010-01-11 08:45 am (UTC)Про оптимизации - неужели недостаточно было трассировать исходную VM, зачем вводить еще один слой?
no subject
Date: 2010-01-11 10:01 am (UTC)а тут я так понял можно пушать/попать и лазить по всему стэку (может быть неправильно понял).
no subject
Date: 2010-01-11 10:17 am (UTC)no subject
Date: 2010-01-11 11:10 am (UTC)no subject
Date: 2010-01-12 10:43 am (UTC)И новизна заключается, таким образом, в отказе от отдельного механизма для темпорари и использовании для этого статически выделенных слотов в local variables array.
no subject
Date: 2010-01-12 11:59 am (UTC)Я на новизну вообще не претендую, кстати.
no subject
Date: 2010-01-11 11:07 am (UTC)Про еще один слой - была шальная мысль, что получится увидеть более интересные сочетания микрокоманд и так родить новые интересные команды. В принципе, трассу МикроВМ можно рассматривать как просто чуть более детальную трассу оригинальной ВМ.
no subject
Date: 2010-03-30 01:15 pm (UTC)Там либо фрагменты кода надо местами поменять, либо я ничего не понимаю.
У меня в VM идеи похожи, только формат команд поддерживает константы.
[ijne* ][jmp_addr][ v1_addr][ v2_addr]
[imovc** ][dst_addr][ 0x000000FA ]
* (integer) jump if not equal
** (integer) move constant
no subject
Date: 2010-03-30 02:17 pm (UTC)У меня формат команд тоже поддерживает константы. У инструкций есть модификаторы, говорящие как трактовать аргументы - как константы или как смещения переменных.
no subject
Date: 2010-03-30 02:59 pm (UTC)А, понял наконец-то, спасибо.