Добавил:
Upload Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:
kernigan_paik.doc
Скачиваний:
0
Добавлен:
01.07.2025
Размер:
2.91 Mб
Скачать

9.7. Компиляция "налету"

В предыдущем разделе мы говорили о программах, которые пишут про­граммы. В каждом примере программы генерировались в виде исходного кода и, стало быть, для запуска должны были быть скомпилированы или интерпретированы. Однако возможно сгенерировать код, который можно запускать сразу, создавая машинные инструкции, а не исходный текст. Такой процесс известен как компиляция "на лету" (on the fly) или "как раз вовремя" (just in time); первый термин появился раньше, однако послед­ний — включая его акроним JIT — более популярен20.

Очевидно, что скомпилированный код по определению получается непереносимым — его можно запустить только на конкретном типе про­цессора, зато он может получиться весьма скоростным. Рассмотрим та­кое выражение:

max(b, с/2)

Здесь нужно вычислить с, поделить его на 2, сравнить результат с b и вы­брать большее из значений. Если мы будем вычислять это выражение, используя виртуальную машину, которую мы в общих чертах описали в начале этой главы, то хотелось бы избежать проверки деления на ноль в divop: поскольку 2 никогда не будет нулем, такая проверка попросту бессмысленна. Однако ни в одном из проектов, придуманных нами для реализации этой виртуальной машины, нет возможности избежать этой проверки — во всех реализациях операции деления проверка делителя на ноль осуществляется в обязательном порядке.

Вот здесь-то нам и может помочь динамическая генерация кода. Если мы будем создавать непосредственно код для выражения, а не использо­вать предопределенные операции, мы сможем исключить проверку деле­ния на ноль для делителей, которые заведомо отличны от нуля. На самом деле мы можем пойти еще дальше: если все выражение является констан­той, как, например, mах(3*3, 4/2), мы можем вычислить его единожды, при генерации кода, и заменять константой-значением, в данном случае числом 9. Если такое выражение используется в цикле, то мы экономим время на его вычисление при каждом проходе цикла. При достаточно большом числе повторений цикла мы с лихвой окупим время, потрачен­ное на дополнительный разбор выражения при генерации кода.

Основная идея состоит в том, что способ записи позволяет нам вооб­ще выразить суть проблемы, а компилятор для выбранного способа за­писи может специально оптимизировать код конкретного вычисления. Например, в виртуальной машине для регулярных выражений у нас, скорее всего, будет оператор для сравнения с символом:

int matchchar(int literal, char *text)

{

return *text == literal;

}

Однако, когда мы генерируем код для конкретного шаблона, значение этого literal фиксировано, например ' х', так что мы можем вместо по­казанного выше сравнения использовать оператор вроде следующего:

int matchx(char *text)

{

return *text == 'x';

}

И затем, вместо того чтобы предварительно определять специальный оператор для значения каждого символа-литеры, мы можем поступить проще: генерировать код для операторов, которые нам будут действи­тельно нужны для данного выражения. Применив эту идею для полного набора операторов, мы можем написать JIT-компилятор, который будет транслировать заданное регулярное выражение в специальный код, оп­тимизированный именно под это выражение.

Кен Томпсон (Ken Thompson) именно это и сделал в 1967 году для реа­лизации регулярных выражений на машине IBM 7094. Его версия генери­ровала в двоичном коде небольшие блоки команд этой машины для раз­личных операторов выражения, сшивала их вместе и затем запускала получившуюся программу, просто вызвав ее, совсем как обычную функ­цию. Схожие технологии можно применить для создания специфических последовательностей команд для обновлений экрана в графических сис­темах, где может быть так много различных случаев, что гораздо более эф­фективно создавать динамический код для каждого из них, чем расписать их все заранее или включить сложные проверки в более общем коде.

Демонстрация того, что же включает в себя создание полноценного JIT-компилятора, неизбежно вынудит нас обратиться к деталям конк­ретных наборов команд, однако все же стоит потратить некоторое время, чтобы на деле гюнять, как работает такая система. В оставшейся части этого параграфа для нас важно будет понимание сути, идеи происходя­щего, а не деталей конкретных реализаций.

Итак, вспомним, в каком виде мы оставили нашу виртуальную маши­ну, — структура ее выглядела примерно так:

Code code[NCODE];

int stack[NSTACK];

int stackp;

int pc; /* программный счетчик */

…..

Tree *t;

t = parse();

pc = generate(0, t);

code[pc].op = NULL;

stackp = 0;

pc = 0;

while (code[pc].op != NULL)

(*code[pc++].op)();

return stack[0];

Для того чтобы адаптировать этот код для JIT-компиляции, в него надо внести некоторые изменения. Во-первых, массив code будет теперь не массивом указателей на функции, а массивом исполняемых команд.

Будут ли эти команды иметь тип char, int или long зависит только от того процессора, под который мы компилируем; предположим, что это будет int. После того как код будет сгенерирован, мы вызываем его как функцию. Никакого виртуального счетчика команд программы в новом коде не будет, поскольку обход кода за нас теперь будет выполнять соб­ственно исполнительный цикл процессора; по окончании вычисления результат будет возвращаться — совсем как в обычной функции. Далее, мы можем выбрать — поддерживать ли нам отдельный стек операндов для нашей машины или воспользоваться стеком самого процессора. У каждого из этих вариантов есть свои преимущества; мы решили остать­ся верными отдельному стеку и сконцентрироваться на деталях самого кода. Теперь реализация выглядит таким образом:

typedef int Code;

Code code[NCODE];

int codep;

int stack[NSTACK];

int stackp;

….

Tree *t;

void (*fn)(void);

int pc;

t = parse();

pc = generate(0, t);

genreturn(pc); /* генерация последовательности */

/* команд для возврата из функции */

stackp = 0;

flushcaches(); /* синхронизация памяти с процессором */

fn = (void(*)(void)) code; /* преобразование массива */

/* в указатель на функцию */

(*fn)(); /* вызов полученной функции */

return stack[0];

После того как generate завершит работу, genreturn вставит команды, которые обусловят передачу управления от сгенерированного кода к eval.

Функция flushcaches отвечает за шаги, необходимые для подготовки процессора к запуску свежесозданного кода. Современные машины ра­ботают быстро, в частности благодаря наличию кэшей для команд и дан­ных, а также конвейеров (pipeline), которые отвечают за выполнение сра­зу нескольких подряд идущих команд. Эти кэши и конвейеры исходят из предположения, что код не изменяется; если же мы генерируем этот код непосредственно перед запуском, то процессор может оказаться в затруднении: ему нужно обязательно очистить свой конвейер и кэши для исполнения новых команд. Эти операции очень сильно зависят от конкретного компьютера, и, соответственно, реализация f lushcaches бу­дет в каждом случае совершенно уникальной.

Замечательное выражение (void (*)(void)) code преобразует адрес массива, содержащего сгенерированные команды, в указатель на функ­цию, который можно было бы использовать для вызова нашего кода.

Технически не так трудно сгенерировать сам код, однако, конечно, для того чтобы сделать это эффективно, придется позаниматься инженерной деятельностью. Начнем с некоторых строительных блоков. Как и раньше, массив code и индекс внутри него заполняются во время компиляции. Для простоты мы повторим свой старый прием — сделаем их оба глобальны­ми. Затем мы можем написать функцию для записи команд:

/* emit: добавляет команду к потоку кода */

void emit(Code inst)

{

code[codep++] = inst;

}

Сами команды могут определяться макросами, зависящими от процес­сора, или небольшими функциями, которые собирали бы код, заполняя поля в командном слове инструкции. Гипотетически мы могли бы завес­ти функцию popreg, которая бы генерировала код для выталкивания зна­чения из стека и сохраняла его в регистре процессора, и функцию pushreg, которая бы генерировала код для получения значения, храня­щегося в регистре процессора, и заталкивания его в стек. Наша обнов­ленная функция addop будет использовать некие их аналоги, применяя некоторые предопределенные константы, описывающие команды (вро­де ADDINST) и их расположение (различные позиции сдвигов SHIFT, кото­рые определяют формат командного слова):

/* addop: генерирует команду ADD */

void addop(void)

{

Code inst;

popreg(2); /* выборка из стека в регистр 2 */

popreg(1); /* выборка из стека в регистр 1 */

inst = ADDINST << INSTSHIFT;

inst | = (R1) << OP1SHIFT;

inst | = (R2) << OP2SHIFT;

emit(inst); /* выполнить ADD R1, R2 */

pushreg(2): /* загрузить значение R2 в сгек */ }

Это, однако, только самое начало. Если бы мы писали настоящий JIT-ком-пилятор, нам бы пришлось заняться оптимизацией. При прибавлении константы нам нет нужды грузить ее в стек, вынимать оттуда и после это­го прибавлять: мы можем прибавить ее сразу. Должное внимание к подоб­ным случаям помогает избавиться от множества излишеств. Однако даже в теперешнем своем виде функция addop будет выполняться гораздо быст­рее, чем в наших более ранних версиях, поскольку различные операторы уже не сшиты воедино вызовами функций. Вместо этого код, исполняю­щий их, располагается теперь в памяти в виде единого блока команд, и для нас все сшивается непосредственно счетчиком команд процессора.

Теперешняя реализация функции generate выглядит весьма похоже на реализацию виртуальной машины, но на этот раз она задает реальные машинные команды вместо указателей на предопределенные функции. Чтобы генерировать более эффективный код, следовало бы потратить усилия на избавление от лишних констант и другие виды оптимизации.

В нашем ураганном экскурсе в генерацию кода мы бросили лишь са­мый поверхностный взгляд на некоторые из технологий, применяемых в настоящих компиляторах, некоторые же вообще обошли молчанием. При этом мы лишь попутно затронули ряд аспектов, связанных со слож­ностью организации современных процессоров. Но мы показали, как программа может анализировать описание проблемы, чтобы создать специальный код, который наиболее эффективен для конкретной зада­чи. Изложенные выше идеи вы можете использовать для создания быст­рой версии grep, для реализации небольшого языка программирования имени себя, для проектирования и создания виртуальной машины, оп­тимизированной для решения специфических задач, или даже для со­здания компилятора для какого-нибудь интересного языка.

Между регулярным выражением и программой на C++ есть, конечно, немалая разница, но суть у них одна — это всего лишь нотации для реше­ния проблем. При правильной нотации многие проблемы становятся го­раздо более простыми. А проектирование и реализация выбранной нота­ции может дать массу удовольствия.

Упражнение 9-18

JIT-компилятор сгенерирует более быстрый код, если сможет заме­нить выражения, содержащие только константы, такие как mах(3*3, 4/2), их значением. Опознав такое выражение, как он должен вычислять его значение?

Упражнение 9-19

Как бы вы тестировали JIT-компилятор?

Соседние файлы в предмете [НЕСОРТИРОВАННОЕ]