Разные мысли о метавычислениях, суперкомпиляции, специализации программ. И об их применениях. Желающие приглашаются в соавторы блога.

Показаны сообщения с ярлыком частичные вычисления. Показать все сообщения
Показаны сообщения с ярлыком частичные вычисления. Показать все сообщения

вторник, 15 марта 2011 г.

Заметки о суперкомпиляции

Здесь, для удобства, собраны в одном месте ссылки на несколько заметок о суперкомпиляции. Время от времени, этот список будет пополняться (благодаря тому, что в Блоггере у авторов есть возможность редактировать послания задним числом). Как говорится, "прошлое непредсказуемо"!

четверг, 27 августа 2009 г.

Что означает по-японски "Ёсихико Футамура"

В предыдущих посланиях упоминались три "проекции Футамуры" (независимо открытые также и Валентином Турчиным).

И у некоторых господ/коллег/товарищей возник интересный вопрос: как пишется по-японски "Ёсихико Футамура" и что сие означает?

По-японски это пишется так:

二村良彦

При этом сначала пишется имя клана ("фамилии"), а потом - личное имя. Те 二村 - это Фута-мура, а 良彦 - это Ёси-хико. С точки зрения японцев это совершенно естественно: сначала сообщается самая важная информация (имя множества), а потом - менее важная (имя его элемента).

В старинных русских былинах, кстати, тоже типичной является ситуация, когда "доброго молодца" спрашивают: "А скажи-ка ты, добрый молодец, какого ты роду-племени, отечества-молодечества?" Т.е. на первом месте интересуются родоплеменной принадлежностью и происхождением (от кого и где), а уже потом переходят на какие-то личные детали. (А с точки зрения западноевропейского индивидуализма более важным считается как раз личное имя...)

Имя клана 二村 состоит из двух иероглифов (или, как говорят сами японцы, "кандзи" = "японских букв"). 二 (фута) означает идею "двух" или "двойственности", а 村 (мура) означает "деревня". Как это однозначно интерпретировать русскими словами, сказать трудно, поскольку 二 означает "два" в весьма абстрактном смысле. Это можно истолковать как "две деревни", "обе деревни", "двойная деревня", "сдвоенная деревня".

Почему иероглиф для "двойки" пишется в виде двух палочек - достаточно понятно... А как устроен иероглиф 村 обозначающий "деревню"? Он состоит из двух частей. Левая половинка - это стилизованное дерево (русское слово "деревня" тоже происходит от "дерева"), а правая половинка выражает идею "измерения" и "порядка". Состоит из стилизованного изображения руки (с тремя пальцами!) и штриха. Одно из объяснений состоит в том, что "измерение" изображено как измерение пульса. Т.е. дополнительный штрих, это то ли палец, приложенный к руке, то ли один толчок крови.

Итак, в совокупности имеем 村. Т.е. "деревья" или "лес" сталкиваются с идеей "порядка". А деревня - она ведь и стоит на грани между "цивилизацией" и "дикой природой, лесом"...

Теперь посмотрим на личное имя 良彦 (Ёси-хико, или в японской слоговой фонетической записи よしひこ = ё-си-хи-ко). Сразу заметим, что よし наиболее адекватно (хотя и не абсолютно точно) передается кириллическими буквами как "ёси". Проблема в том, что в японском языке звук "с" довольно похож на русский "с", но чуть-чуть шепелявый (для избавления от подобной легкой шепелявости, у нас детей к логопедам водят, а в Японии, видимо, наоборот, для ее приобретения). Поэтому если написать し кириллическими буквами как "си", то невыраженной оказывается шепелявость звука. Т.е., с точки зрения японца, "с" - звук правильный, но слегка "дефектный". А вот если написать "ши", то получается совсем плохо, поскольку в японском языке нет звука даже отдаленно похожего на наш "ы". А как учат наших детей в школе: "Жи-ши - пишется с буквой И!" Т.е. русский человек (без особой подготовки) после "ж" и "ш" звук "и" произнести вообще не в состоянии: он всегда говорит "ы"! А по законам русской орфографии полагается писать "и", а произносить "ы" (что детям с большим трудом и вдалбливают"). Поэтому, если よしひこ записать как Ёшыхико, то, с точки зрения японцев, звучание получается просто ужасное...

Итак, смотрим на 良彦. Первый иероглиф 良 (ёси) выражает идею чего-то "хорошего", "ладного" и "секучего" (в смысле умственной полноценности). Состоит этот иероглиф из двух элементов. Сверху "капля", а снизу - "серебро". Вполне красочный образ: "капля серебра"...

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

Итак, похоже, что наиболее адекватно понятие 良彦 (ёси-хико) можно передать по-русски, как "добрый молодец" или как "крутой парень"! (Ведь там содержиться не только идея внешней статности, но еще и "компетентности" или "эффективности".)

Итого, получаем:

二村良彦 = из двойной деревни добрый молодец

А еще некоторые удивляются, что у японцев образ мышления не совсем такой, как у нас...

пятница, 31 июля 2009 г.

Многостадийное программирование, метавычисления и проблемно-ориентированные языки

По поводу послания Проблемно-ориентированные языки и суперкомпиляция возник следующий правильный и законный вопрос. Ну хорошо, проблемно-ориентированные языки (ПО-языки) можно реализовывать с помощью интерпретаторов/комбинаторов с последующей обработкой программы частичным вычислителем или суперкомпилятором. Ну, и чем же отличается этот подход, от "многостадийного программирования" (multi-stage programming), реализованного в MetaOCaml, Template Haskell и шаблонах (templates) C++?

K. Czarnecki, J.T. O'Donnell, J. Striegnitz, W. Taha, DSL implementation in MetaOCaml, Template Haskell and C++, in: C. Lengauer, D. Batory, C. Consel, M. Odersky (Eds.), Domain-Specific Program Generation, in: Lecture Notes in Computer Science, vol. 3016, Springer-Verlag, 2004, pp. 51-72.

К сожалению, я не являюсь знатоком ни MetaOCaml, ни Template Haskell. Но ответить всё же попытаюсь. Поэтому заранее предупреждаю, что мой ответ может оказаться неправильным. Ну, или правильным - да не совсем... Тогда, как я надеюсь, общественность укажет на мои ошибки и заблуждения, и я постараюсь встать на путь исправления! :-)

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

Какие проблемы возникают при таком подходе?

  • Поскольку "перевоспитание" интерпретатора в компилятор производится вручную, нет гарантий, что компилятор, полученный таким способом, будет эквивалентен исходному интерпретатору.

  • Программисту для реализации ПО-языка недостаточно знать один язык (тот, на котором пишется интерпретатор). Нужно изучить ещё дополнительный язык крючочков, с помощью которых делается превращение интерпретатора в компилятор.

В случае Template Haskell (если я правильно понимаю), предлагается начинать не с интерпретатора, а прямо сразу писать компилятор. Особенности такого подхода следующие:

  • Вопрос о корректности реализации DSL теряет смысл. Если нет интерпретатора, то и непонятно, по отношению к чему корректен компилятор? Как человек напишет - так и будет "правильно" по-определению.

  • Для реализации ПО-языка недостаточно знать только Haskell: нужно ещё изучить колёсики, специфические для Template Haskell: способ кодирования хаскельных программ в виде алгебраических типов данных, функции и монады, предназначенные для манипуляций с хаскельными программами.

В случае шаблонов C++, как и для Template Haskell предлагается обходиться без интерпретатора и сразу изготавливать компилятор. И проблемы получаются те же самые:

  • Вопрос о корректности реализации через шаблоны C++ даже непонятно как и поставить?

  • Для реализации ПО-языка недостаточно знать сам C++: нужно ещё изучить язык шаблонов. А язык этот - могучий, алгоритмически полный. Что на Лиспе можно написать - то и на языке шаблонов C++. Правда многие вещи (по сравнению с Лиспом) при этом приходится делать, как написали бы в милицейском протоколе, "в извращённой форме".

А подход основанный на использовании частичных вычислений и/или суперкомпиляции отличается следующим:

  • ПО-язык предлагается реализовать либо с помощью интерпретатора, либо в виде набора комбинаторов. При этом и интерпретатор, и комбинаторы можно отлаживать традиционными средствами.

  • Затем на интерпретатор/комбинаторы напускается частичный вычислитель/суперкомпилятор, который генерирует более эффективную остаточную программу, из которой изгнан интерпретатор/комбинаторы.

  • Корректность остаточной программы обеспечивает частичный вычислитель/суперкомпилятор. Точнее тот, кто их изготовил. :-) А тот, кто пишет интерпретатор/комбинаторы от этой заботы избавлен.

  • Программист, реализующий ПО-язык должен изучить только один язык: тот, на котором пишется интерпретатор/комбинаторы и может мыслить только в понятиях этого языка. Также не требуется "расщеплять сознание" и мыслить в двух временах одновременно (времени компиляции и времени исполнения). Этот тяжкий (и вредный для психики) труд остаётся для разработчика частичного вычислителя/суперкомпилятора.

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

суббота, 6 июня 2009 г.

Что лучше: частичные вычисления или суперкомпиляция?

Вот, я писал-писал про суперкомпиляцию, и всё время её расхваливал. А теперь, для разнообразия, вдруг захотелось её поругать. А для этого нужно сравнить её с чем-то похожим, и сказать, что она хуже этого похожего.

А ближе всего к суперкомпиляции находятся "частичные вычисления" (partial evaluation).

Но, поскольку хочется быть хоть и суровым, но справедливым, я, для начала, частичные вычисления поругаю, и только после этого их похвалю!

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

Пусть целые числа представлены в виде Z, S(Z), S(S(Z)), ... Рассмотрим программу

f(u) = g(u, Z);
g(Z, y) = y;
g(S(x), y) = g(x, S(y));

Функция f является "тождественной" в том смысле, что если на вход подать число u, то f (u) выдаст u.

Если подвергнуть g(u, Z) прогонке, то (на одной из ветвей дерева) получается бесконечная последовательность термов:

g(u, Z) --> g(u1, S(Z)) --> g(u2, S(S(Z))) -->

Если в суперкомпилятор вделан "свисток", основанный на гомеоморфном вложении, то суперкомпилятор замечает, что g(u, Z) гомеоморфно вложено в g(u1, S(Z)). Или, говоря по-простому, если в g(u1, S(Z)) подтереть конструктор S, то получится g(u1, Z), что (с точностью до имён переменных) совпадает с g(u, Z).

Поэтому суперкомпилятор делает обобщение и зацикливание. Можно убедиться в этом на примере суперкомпилятора SPSC: addAcc(a, Z).

А в случае частичного вычисления, получается другая картина. В частичных вычислителях основная идея - вычислять все константные выражения, какие можно. Т.е., "распространять константы", но не локально, а глобально, ради этого раскрывая in-line вызовы функций или генерируя специализированные версии функций для разных значений аргументов. Но, в отличие от суперкомпиляции, конфигурации из вложенных вызовов функций при этом не рассматриваются.

Если частичный вычислитель - типа "offline", то он сначала размечает программу, чтобы узнать, какие подвыражения и параметры функций будут заведомо известны. Для этого символически выполняются операции над данными, а данные делятся на две категории S и D. S - известные данные, а D - неизвестные.

Основная идея "проста как лапоть":

S+S = S
S+D = D
D+S = D
D+D = D

В случае простого функционального языка, нужно решить, какие из параметров функций будут "статическими" (S), а какие - динамическими (D). А тип всех остальных конструкций однозначно определяется на основе типов аргументов.

В случае нашего примера есть две функции f и g. Для f возможны 2 разметки: f(S) и f(D). А для g - 4 разметки: g(S, S), g(S, D), g(D, S), g(D,D).

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

Когда процесс стабилизируется (достигается "неподвижная точка"), получается корректная разметка. D "испортили" уже всё, что смогли.

Применяем этот принцип к нашему примеру.

Для начала перепишем программу в такой форме, которая является более "естественной" с точки зрения большинства частичных вычислителей (и заодно перейдём к "нормальным" целым числам, которые для частичного вычислителя приятнее):

f(u) = g(u, z);

g(x, y) = if x == 0 then y else g(x-1, y+1);

Для примера будем считать, что нам неизвестно, что будет на входе программы. и задаём начальную разметку: f(D), g(S,S).

  D      D  S
f(u) = g(u, z);
  S  S       S           S        S    S
g(x, y) = if x == 0 then y else g(x-1, y+1);

Видно, что в одном месте g вызывается с первым параметром, имеющим пометку D. Меняем разметку параметров g с g(S,S) на g(D,S). Получается

  D      D  S
f(u) = g(u, z);
  D  S       D           S        D    S
g(x, y) = if x == 0 then y else g(x-1, y+1);


И на этом разметка стабилизируется! Везде S получается только из S. Поэтому, разметка "корректна".

Теперь надо принять решение, раскрывать ли вызовы функции g или генерировать её специализированные версии? Не раскрывать - это более осторожная тактика, применим её. Тогда частичный вычислитель действует так. У f разметка не содержит S: f(D). Поэтому, в остаточной программе генерируется только одна версия функции f. А разметка функции g содержит S: g(D,S). Это означает, что в процессе метавычислений второй аргумент g будет известен, и что для разных констант k будут сгенерированы специализированные версии g, такие, что g_k(x) = g(x, k). Принцип простой: если где-то получился вызов вида g(x,k), заменяем его на g_k(x) и генерируем определение функции g_k. При этом, с технической точки зрения, можно откладывать переименование вызовов g(x, k) в g_k(x) можно делать не сразу, а посредством отдельного прохода по программе. Т.е., просто считается, что значения S-параметров - это "часть имени функции".

А начинается процесс с генерации определения для входной функции f.

Получается так (после вычисления всех подвыражений, содержащих только S-переменные, значение которых известно):

g(u) = g(u, 0);
g(x, 0) = if x == 0 then 0 else g(x-1, 1);
g(x, 1) = if x == 0 then 1 else g(x-1, 2);
g(x, 2) = if x == 0 then 2 else g(x-1, 3);
g(x, 3) = if x == 0 then 3 else g(x-1, 4);
...

Или, после переименования функций,

g(u) = g_0(u, 0);
g_0(x) = if x == 0 then 0 else g_1(x-1);
g_1(x) = if x == 0 then 1 else g_2(x-1);
g_2(x) = if x == 0 then 2 else g_3(x-1);
g_3(x) = if x == 0 then 3 else g_4(x-1);
...

Генерируется бесконечная программа, которая для каждого k содержит определение функции вида

g_k(x) = if x == 0 then k else g_k+1(x-1);

Эта проблема характерна практически для всех частичных вычислителей (особенно - для самоприменимых). Но стоит не так остро для run-time specialization, поскольку можно не генерировать специализированные версии функций "про запас", а по мере надобности. А понадобиться могут не все варианты.

Из сказанного может сложиться ложное впечатление, что частичные вычислители, по сравнению с суперкомпиляторами, - это что-то ущербное. Зачем вообще заниматься частичными вычислениями, если суперкомпиляция "круче"? Но это - не совсем так.

Класс программ, с которыми успешно "справляются" частичные вычислители - некоторое подмножество, по сравнению с классом программ, с которыми можно делать что-то интересное с помощью суперкомпиляции.

Однако, если частичный вычислитель с какими-то программами всё же справляется, он делает это гораздо лучше, чем суперкомпилятор!

Разница здесь примерно такая же, как между лошадью и автомобилем. Если нужно ехать по шоссе, то автомобиль - быстрее лошади, и грузоподъёмность у него больше. Но если требуется проехать, например, по горной тропе...

Особенно сильно преимущества частичных вычислений перед суперкомпиляцией проявляются в случае, если генерация остаточной программы идёт в два этапа:

  • Строится разметка программы, классифицирующая все переменные и операции на "статические" (которые можно будет выполнить) и на "динамические" (которые "выпадут в осадок", т.е. останутся в остаточной программу).

  • Выполняется генерация остаточной программы в соответствии с разметкой. При этом всё, что помечено как "статическое", быстро и "бездумно" вычисляется.

Первое преимущество такого подхода очевидно: генерация остаточной программы идёт быстрее.

Второе преимущество является, может быть, ещё более важным. Это - предсказуемость и стабильность результатов. Ибо уже после стадии разметки становится понятно, какие именно части исходной программы исчезнут, а какие - "выпадут" в остаточную программу, и какую именно структуру будет иметь остаточная программа.

А слабости суперкомпиляции являются "оборотной стороной" её силы. Суперкомпилятор "слишком умён", а значит - менее предсказуем! Результаты его применения в конкретной ситуации могут быть весьма неожиданными... Кстати, одно из определений "искусственного интеллекта" так и звучит: если результаты работы программы предсказуемы, значит, "интеллекта" в ней нет (она просто что-то "вычисляет" - и всё), а если результат предсказать невозможно - значит "интеллекта" в ней навалом. :-)

Понятно, что если метавычисления используются для анализа программ (т.е. выявления и доказательства их свойств), то суперкомпилятор - это "самое что надо". Для того и анализируем, чтобы узнать то, что заранее неочевидно. А если метавычисления используются для специализации программ (или, говоря по-простому, оптимизации программы под конкретные условия её выполнения), то желательна предсказуемость.

Есть и ещё одна интересная ситуация, когда проявляется преимущество (двухэтапных) частичных вычислений перед суперкомпиляцией: когда мы хотим применить специализатор программ к самому себе! Например, если у нас есть специализатор spec и интерпретатор int, то можно преобразовать интерпретатор в компилятор вычислив spec(spec,int). Это - "вторая проекция Футамуры" (которая была независимо открыта и Турчиным, хотя и немного позже).

Чем интересно выражение spec(spec,int)? Да тем, что специализатору предлагается "заглянуть в свою собственную душу" и разобраться в своём собственном поведении по отношению к самому себе. Здесь возникает некоторое противоречие: умному-то легко разобраться в душе дурака, но для дурака душа умного - потёмки. Вот и получается, что если специализатор - дурак, то самоприменять его бесполезно, поскольку сам в себе он разобраться не в состоянии. А если он слишком умён - то он всё равно не способен разобраться в себе, именно в силу своей сложности.

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

Подробнее об одном из таких частичных вычислителей можно почитать здесь:

  • С.А.Романенко. Генератор компиляторов, порожденный самоприменением специализатора, может иметь ясную и естественную структуру. - М.:ИПМ им.М.В.Келдыша АН СССР, 1987, препринт N 26. - 35 с. PDF, DJVU

  • S.A.Romanenko. A Compiler Generator Produced by a Self-Applicable Specializer Can Have a Surprisingly Natural and Understandable Structure. In D.Bjorner, A.P.Ershov and N.D.Jones, editors, Partial Evaluation and Mixed Computation, pages 445-463, North-Holland, 1988. PDF, DJVU

  • S. A. Romanenko. Arity Raiser and its Use in Program Specialization. In Proceedings of the Third European Symposium on Programming on ESOP '90 (Copenhagen, Denmark). N. Jones, Ed. Springer-Verlag New York, New York, NY, 341-360. PDF, DJVU

Постоянные читатели