Процессоры и генерации

Недавняя шумиха вокруг секретного "супер-проекта" Transmeta уже практически сошла на нет. Два ведущих производителя ПК (Compaq и Dell) по сути еще до серийного производства новых процессоров отказались от их использования, в поведении третьего — IBM — наблюдается неафишируемый, но весьма ощутимый скепсис. Точку над "i" в процессе рыночного становления Transmeta поспешила поставить Intel — ответный удар империи оказался на удивление своевременным и мощным.

К слову, фактор выхода такого игрока, как Intel, в новый сегмент рынка (низкопотребляющих производительных CPU), во многом инспирированный именно накалом страстей вокруг Transmeta, по неизвестным причинам не вызвал широкого резонанса в прессе. А напрасно — достойные Копперфилда скорость и ловкость, с которыми полупроводниковый гигант обнародовал 2-ваттную версию Pentium, позволяют догадываться: подобные разработки существовали если не очень, то сравнительно давно. А это означает, что Intel пыталась поддерживать некоторое равновесие, оставляя обширное поле действия игрокам, не предъявляющим рыночных претензий на "настольную часть пирога". Теперь равновесие нарушено, и, в отличие от так и не успевшей проявить себя Transmeta, в неизбежно тяжелом положении оказываются замечательные чипмейкеры (в первую очередь, клонов MIPS — именно они попадают в конкурирующий диапазон производительности и потребляемой мощности). Так что по-настоящему серьезные последствия "e-CPU-бизнеса" в Transmeta-исполнении нам предстоит увидеть в недалеком будущем.

В сюжете этой картины "кисти неизвестного абстракциониста" остался вне внимания еще один важный фрагмент. Недавно опубликованные в Сети результаты тестирования JIT-компиляторов для Java выявили весьма неожиданные, но предсказуемые факты. А именно, Java-код, "перемолоченный" новыми компиляторами… на ряде задач существенно превосходит по быстродействию скомпилированные программы на C и C++ — основных языках системного и прикладного программирования. Близость идей JIT идеям, заложенным в процессоры Transmeta, и одновременная технологическая пропасть между ними (JIT — быстрая генерация исполняемого кода для "классической" архитектуры целевого процессора из кодов "метамашины", Transmeta — быстрая генерация исполняемого кода метамашины из кодов процессора "классической" архитектуры) дают в руки конкурентов Transmeta еще одно "смертоносное оружие". Его эффективность подтверждается успехами JIT-технологии, а технологическая привлекательность — появлением экономичных и мощных CPU. И, что самое главное, оно обладает неоспоримой потребительской привлекательностью, позволяя существенно ускорить выполнение программ без аппаратной модернизации.

RISC, CISC, VLIW, MISC

Раз уж речь зашла о процессорах, появление этого заголовка неизбежно, несмотря на то, что классификационные попытки повторялись неоднократно. В быстроменяющемся мире CPU границы между классами исчезают с реактивной скоростью, а без их определения все дальнейшие рассуждения могут приобрести "пресс-релизный" характер (в худшем смысле этого слова). Сегодня уже точно можно утверждать, что той или иной "чистой" архитектуры не существует (за редким исключением MISC — о ней далее).

То, что недавно называлось RISC (компьютер с сокращенным набором команд), трансформировалось в более определенное понятие: "регистрово-насыщенный процессор, ориентированный на операции типа load/store". К сожалению, хоть каких-нибудь числовых оценок, позволяющих утверждать, что тот или иной процессор относится к данному классу, не существует. Но RISC остаются узнаваемыми — регистров у них действительно много (до нескольких сотен), и они действительно "не любят" операций типа "регистр-память" или "память-память": все операнды, необходимые для выполнения той или иной команды, должны быть обязательно размещены в рабочих регистрах процессора. Эта особенность RISC очень важна — она является и достоинством, и недостатком одновременно. С одной стороны, load/store-архитектура позволяет реализовывать очень быстрые однотактные многооперандные команды (например, классическое в цифровой обработке "умножение с накоплением" — умножение двух операндов и сложение результата с третьим), и это просто замечательно. С другой, управление распределением регистров для разработчиков компиляторов превращается в настоящий кошмар, а необходимость временного сохранения большого числа регистров при вызовах подпрограмм или обработчиков прерываний требует многочисленных архитектурных ухищрений.

Соответственно CISC-семейство в рамках новой классификации остается антиподом RISC и трансформируется в "регистрово-ненасыщенную архитектуру, способную равноценно использовать регистры и оперативную память". В CISC не запрещены команды типа "сложение содержимого регистра с содержимым ячейки оперативной памяти с адресом A" или даже "сложение содержимого ячеек оперативной памяти с адресами A и B и размещение результатов в ячейке ОЗУ с адресом C". Естественно, что такие операции являются в некотором роде абстракциями — для их выполнения процессор осуществляет копирование операндов во внутренние регистры (обычно недоступные программисту в явном виде). Опять же очевидно, что эта особенность не позволяет реализовать "ну очень быструю" систему команд — за один такт с такими задачами не управиться. Зато разработчикам генераторов кода компиляторов становится легче — как ни "насыщай" процессор регистрами, в ОЗУ ячеек все равно больше. Несмотря на это конструкторы процессоров CISC-семейства, как и их коллеги из конкурирующей RISC-отрасли, ухищрениями (подчас лихими) также не брезгуют.

Если с RISC и CISC все более или менее понятно, то недавно прошедшая пик популярности VLIW-архитектура "по молодости лет" остается в некотором роде загадкой. Хотя… совсем недавно автору "в завалах" отчетов о НИР MIT (Массачусетского Технологического) попалось весьма забавное определение VLIW, построенное по принципу "от известного": VLIW — это "большая MMX-архитектура". Действительно, VLIW фактически подразумевает создание одного "большого" и в некотором роде виртуального процессора из параллельной группы "маленьких". Новизны бы здесь не было никакой, если бы не программный способ этого объединения — за его формирование отвечает генератор кода компилятора. Архитектурные особенности вынуждают разработчиков использовать в качестве "подпроцессоров" RISC-машины: только механизм load/store позволяет "упаковать" в одно длинное слово команды группу подкоманд, выполняющихся подпроцессорами.

Все три предыдущих класса объединяют две принципиальные детали: во-первых, существование отдельного адресного пространства, в котором размещаются регистры процессоров; во-вторых, использование абстракции стека для сугубо служебных целей. Что это значит, лучше всего понять на примере простых 8-битовых RISC и CISC-машин AVR и 8051 производства компании Atmel.

Итак, AVR. Типичный RISC — только load/store команды, достаточно много регистров (32 для 8-битовой машины — это действительно много). Рассмотрим команду ADD Rd,Rr — сложение содержимого двух регистров с размещением результата в одном из них. В двоичном представлении (единственном, понимаемом процессором), она выглядит так:

000011rdddddrrrr

Биты, обозначенные буквами r и d, образуют адреса регистров, участвующих в операции, следовательно, регистровое адресное пространство (РАП) действительно существует и его размер в точности соответствует количеству рабочих регистров (5 битов позволяют адресовать ровно 32 регистра). Стек в AVR используется для временного сохранения/восстановления содержимого рабочих и служебных регистров при переходе к выполнению подпрограммы (или обработчика прерывания).

Классический чип 8051, напротив, является образчиком CISC. Причем наглядно демонстрирующим и ухищрения, необходимые для достижения высоких скоростных качеств. В нем существует аналогичная рассмотренной выше команда ADD A,Rr. Ее битовый формат

00101rrr

свидетельствует, что у 8051 всего лишь 8 регистров, находящихся в РАП. А дальше начинаются упомянутые ухищрения — разработчики Intel создали в 8051 четыре таких адресных пространства с возможностью переключения, что позволяет вместо долгого сохранения в стеке рабочих регистров просто переключить одной командой регистровое адресное пространство. Стек в 8051 используется так же, как и в AVR, а дополнительным подтверждением CISC-ориентированности является обширный выбор операндов: от регистров в текущем РАП до содержимого ячеек памяти во внутреннем ОЗУ.

В принципе, обе архитектуры могли бы вообще обходиться без аппаратной реализации стека — он здесь предназначен больше для удобства программирования, чем является вынужденной необходимостью (единственное "но" — сохранение адреса команды, с которой надо продолжить выполнение после обработки подпрограммы/обработчика прерывания; но и эту проблему можно устранить с помощью, например, одного дополнительного регистра).

Последний малочисленный класс архитектур — MISC (компьютер с минимальным набором команд) принципиально отличается и от RISC, и от CISC именно по этим двум критериям. Во-первых, у MISC нет регистрового адресного пространства (а если оно и есть, то недоступно в явном виде программисту), во-вторых, концепция стека играет здесь не второстепенную, а ведущую роль. Это "во-вторых" является неизбежным следствием из "во-первых": только стек позволяет исключить необходимость в адресах регистров, да и в самих регистрах вообще. Соответственно для MISC самая высокая скорость перехода к выполнению подпрограммы гарантирована — здесь не требуется ни одной "лишней" операции. Кроме того, MISC характеризуются очень компактным кодом, простотой реализации как самих процессоров, так и VLIW-подобных архитектур на их основе. Но к достоинствам MISC мы еще вернемся в конце статьи.

Компиляции, генерации, оптимизации

Увы, даже абсолютно не интересующиеся принципами работы компьютеров и технологией их программирования пользователи становятся в буквальном смысле заложниками этих двух важнейших составляющих компьютинга (если более точно, то именно такие пользователи становятся заложниками в первую очередь). На первый взгляд, какое отношение имеет узкоспециализированная отрасль computer science (проектирование генераторов кода компиляторов) к потребностям рядового пользователя? На самом деле, абсолютно непосредственное: все, что мы привыкли называть качеством ПО (показатели надежности, быстродействие, функциональная насыщенность), определяется прямо или косвенно именно совершенством… генератора кода, используемого разработчиками системных и прикладных программ. Что же, кроме истории с Transmeta, происходит в этой "закрытой зоне"?

Если попытаться коротко ответить на последний вопрос, то уместнее всего было бы сказать о грядущей революции. Классическая технология компиляции со статической генерацией кода, похоже, начинает серьезно сдавать позиции. Если еще сравнительно недавно они были незыблемы и надежно укреплены от посягательств со стороны всяческих "динамических ухищрений" невысокой производительностью процессоров, то во времена "гигагерц и гигафлопс" ситуация меняется коренным образом. Но для начала давайте попробуем разобраться со всеми этими "статиками" и "динамиками".

Статическая генерация кода — самый старый и проверенный прием у разработчиков компиляторов. Неочевидность названия не должна смущать — классические (статические) генераторы кода "срабатывают" один раз и формируют то, что мы называем исполняемым файлом. Несомненное достоинство статической генерации — относительная простота реализации. Естественно, что простота эта определяется показателями качества получаемого машинного кода — примитивный статический генератор может использовать всего несколько регистров конкретного процессора, его написание вполне под силу студенту в рамках курсовой работы, но быстродействие получаемых с его помощью программ будет слишком низким. Использование же всех возможностей конкретной архитектуры — дело слишком непростое, и написание кодогенераторов такого класса является "высшим пилотажем" системного программирования, искусством, базирующимся на самых серьезных разработках в области алгебры, топологии и еще десятка смежных дисциплин. Но даже "самый-самый" статический генератор кода принципиально не способен учесть одного "маленького" нюанса — обрабатываемых программой данных, т. е. тех данных, которые используются программой при запуске ее некоторым конкретным пользователем в некотором конкретном случае. Здесь опять же примера не избежать. Предположим, что где-то в теле "программы" встречается такое выражение:

A = B/C ; // переменная A принимает значение частного переменных B и C

Если на этапе компиляции программы значения B и C неизвестны, а они, например, задаются пользователем в ходе исполнения уже скомпилированной программы, статический генератор кода для некоторого виртуального RISC-процессора может сформировать такой фрагмент кода:

LD R2, &B ; Загрузить в регистр 2 значение из ячейки памяти с адресом B

LD R3, &C ; Загрузить в регистр 2 значение из ячейки памяти с адресом C

DIV R1,R2,R3 ; Поместить в регистр 1 частное содержимого регистров 2 и 3

ST &A, R1 ; Записать в память по адресу A содержимое регистра 1

Предположим, что наш виртуальный процессор выполняет команды LD и ST за два такта (это почти всегда справедливо для хороших RISC на операциях с внешней памятью), а команду DIV — за "много" тактов (например, за 16; команды деления вообще обычно являются "долгоиграющими"). Суммарное время (в терминах тактовой частоты) исполнения этого фрагмента кода составит 22 такта.

А теперь представим, что при некотором запуске программы одна из переменных B или C приняла значение, равное целой степени числа 2 (например, просто 2). Знакомые с двоичной системой счисления и архитектурами процессоров хорошо знают, что умножение на 2N равносильно сдвигу содержимого регистра на N битов и что команды сдвигов присутствуют во всех процессорах, и, наконец, что эти команды очень быстрые. Типичное время выполнения команды сдвига в RISC-процессорах — один такт. В нашем примере этот факт означает, что за общность сгенерированной программы нам пришлось в данном конкретном случае расплатиться 15-ю совершенно бессмысленными тактами. Цифра, казалось бы, совершенно незначительная, но если указанный фрагмент кода исполняется в цикле ну с очень большим числом повторений…

Это один специфический и принципиально непреодолимый недостаток статической кодогенерации. Второй намного серьезнее: если мы не располагаем исходными текстами програмы P, а только ее скомпилированным исполняемым файлом для платформы S, то, естественно, ни на какой другой платформе использовать P без эмуляционных сложностей невозможно. К слову, решение от Transmeta или никак, или почти никак не "лечит" это хроническое заболевание — ведь фактически Crusoe осуществляет эмуляцию чуждой архитектуры "на том, что есть". И если у процессоров семейства Sparc более 200 рабочих регистров (да еще и хитро организованных), никакой эмуляцией с приемлемой производительностью исполняемые файлы Sparc на 64-регистровом Crusoe не "погоняешь".

Для преодоления этих "болезней" есть совсем другие "лекарства" — прекрасные и вполне работоспособные разработки из области динамической генерации исполняемого кода (ДГИК). Даже в определении ДГИК существенно отличается от привычных нам понятий: "создание исполняемого кода исполняемым процессом" значительно расширяет понятие просто "исполняемого процесса". Фактически это означает, что или исполняемое приложение должно содержать фрагмент компилятора — динамический кодогенератор, или же он должен быть неотъемлемой частью операционной системы (естественно, что с помощью ДГИК можно исполнять приложения на разных аппаратных платформах, но в рамках одной ОС). Чтобы избежать неоправданно сложных и неэффективных "эмуляционных затрат", в большинстве ДГИК в качестве основного представления исполняемых файлов используется машинно-независимый промежуточный формат: в Java-системах c JIT-кодогенерацией его роль играет байт-код виртуальной машины Java, в конкурирующей (но, к сожалению, малопопулярной) Juice на основе Oberon — так называемые "плоские бинарные файлы" (slim binaries), в "многоцелевом" компиляторе языка C lcc — "BURS-деревья" (и соответствующие алгоритмы). Показатели качества современных ДГИК-систем весьма высоки: так, кодогенератор lcc затрачивает всего 300—400 команд на формирование одной машинной команды (это выполняется при "загрузке" программы и, для частичных фрагментов кода, — в ходе исполнения), созданный же с его помощью код таких типично ресурсоемких задач, как деление/умножение матриц, оказывается быстрее в 4—10 раз по сравнению со статической версией, транслированной хорошим оптимизирующим компилятором. Результаты применения ДГИК, например в операционных системах, также обнадеживают — ядро экспериментальной ОС Synthesis при исполнении на значительно более медленном процессоре оказалось в 56 (!) раз быстрее в выполнении операций байтового чтения/записи файла по сравнению с SunOS. Создатели графических подсистем не обошли вниманием ДГИК, и здесь канонической считается разработка Роба Пайка bitblt ("сердце" оконной подсистемы rio ОС Plan9) — подпрограмма, объединяющая два прямоугольных фрагмента растровой видеопамяти на основе битовых операций. При классическом, статическом, подходе для реализации bitblt требуется порядка 1 MB кода, который получается, кроме того, медленным. Пайк создал ДГИК-вариант bitblt, генерирующий код "самого себя" на основе особенностей исходных растровых фрагментов, что дало возможность повысить быстродействие в худшем случае на порядок.

Одной интересной вариации ДГИК от Hewlett-Packard следует уделить особое внимание. Речь идет о разработке с названием Dynamo. В отличие от "чистых" ДГИК, Dynamo в качестве промежуточного представления использует… непосредственно машинный код целевого компьютера (пока только процессоров PA RISC). Соответственно и целевое назначение Dynamo — оптимизация быстродействия уже скомпилированных программ. Реализация Dynamo является лучшим примером, когда кажущаяся абсурдной идея на деле становится плодотворной: Dynamo — "умный" интерпретатор машинных кодов процессора PA-8000, работающий на процессоре… PA-8000. Принцип работы такого "интерпретатора-акселератора" относительно несложен. Dynamo "исполняет" в режиме интерпретации фрагмент кода программы до тех пор, пока не встретит команду, вызывающую "ветвление" процесса выполнения, в этот момент интерпретатор переключается в режим оптимизации, формирует оптимальный (обновленный) вариант пройденного фрагмента и записывает его в специально выделенную кэш-область оперативной памяти. Затем процесс повторяется уже с новым фрагментом. Если в дальнейшем программа обращается к адресу, принадлежащему одному из оптимизированных фрагментов, Dynamo передает на выполнение процессору его код из своей кэш-памяти. Несомненные достоинства такого подхода — принципиальная независимость "ускорителя" от типов ОС и процессора, полная "незаметность" его для пользователя.

Совместное применение технологий "чистой" и оптимизирующей ДГИК позволяет как значительно улучшить показатели быстродействия уже существующего ПО, так и уничтожить межплатформенный барьер при торговле программами в "закрытом" (исполняемом) формате. Если пользователей Windows, на сегодняшний день существующей только на одной платформе (x86), вторая перспектива практически не интересует, то от первой наверняка никто не откажется. Остается попытаться ответить на один вопрос — когда же подобные Dynamo оптимизаторы пробьют себе путь из мира рабочих станций к настольным системам?

Радикальное решение

ДГИК, несомненно, является очень перспективным направлением. Но… как говорил Жванецкий, "…может, в консерватории что-нибудь надо подправить"? Сама потребность в динамических кодогенераторах-оптимизаторах вызвана, с одной стороны, сегментацией рынка аппаратных платформ, рассчитанных даже для работы с одной ОС (что само по себе неплохо), с другой — принципиальным архитектурным разрывом между современными процессорами и техникой компиляции. Оптимальной для компилируемого кода была, есть и в обозримом будущем останется стековая архитектура, называемая MISC. Почему на сегодняшний день серийно выпускаются всего две-три аппаратные реализации стековых машин, какова судьба достаточно интересного чипа PicoJava и как в дальнейшем будет развиваться это забытое всеми ведущими производителями направление — здесь вопросов, увы, больше, чем ответов.