Сергей Романенко
18 мая 2009 г.
При “прогонке” (символическом/обобщённом исполнении программы) в общем случае получается бесконечное дерево конфигураций. Задача суперкомпиляции - превратить это дерево в конечный граф.
В заметке Что такое суперкомпиляция?
был рассмотрено такое простейшее “правило зацикливания”. Допустим, в дереве
встретился такой узел, который содержит конфигурацию X2, и при этому среди
предков этого узла в дереве есть конфигурация X1, такая, что X1 и X2
совпадают (с точностью до переименования переменных). Понятно, что при таких
условиях нет смысла строить поддерево для X2, поскольку оно будет выглядеть
точно так же, как и поддерево, подвешенное к X1.
Поэтому выполнялась следующая операция. К графу добавлялась обратная стрелка из
X2 в X1, а поддерево, подвешенное к X2 удалялось из графа. Точнее, оно не
удалялось, а вообще не строилось!
Вот так и получался конечный граф. Правда, только для некоторых “особенно хороших” исходных программ.
В заметке Обобщение конфигураций при суперкомпиляции
рассматривалось более сложное “правило зацикливания”. Допустим, что X2 и X1
действительно разные (X1 нельзя превратить в X2 простым переименованием
переменных), но X2 является “частным случаем” X1. В том смысле, что применив
к переменным в X1 некоторую подстановку, можно превратить X1 в X2. Тогда
можно преобразовать X2 таким образом, чтобы она совпала с X1 (с точностью до
переименования переменных). Это делается так: выдумываем новые, уникальные имена
переменных v1, …, vk и X2 приводится к виду
let v1 = E1,…, vk = Ek in E0
где E0 уже совпадает с X1 (с точностью до переименования переменных). На
следующем шаге, let-выражение разбирается на части E0, E1,…, Ek, каждая
из которых обрабатывается отдельно. При этом уже можно добавить к графу обратную
стрелку из E0 в X1.
Переход от X2 к let-выражению, содержащему E0, называется “обобщением”,
поскольку X2 является “частным случаем” E0.
Благодаря обобщению конфигураций, класс программ, для которых получался конечных граф конфигураций, - расширился.
И способ борьбы с разрастанием дерева - понятен. Если встретилась конфигурация, которая является частным случаем уже рассмотренной конфигурации - сводим более специфическую конфигурацию к более общей. Возникает вопрос: достаточно ли этого правила, чтобы получить конечный граф конфигураций для любой исходной программы? Ответ на это - отрицательный… (Видимо, Судьба так распорядилась, чтобы авторам суперкомпиляторов жизнь мёдом не казалась. :-) )
Рассмотрим следующих (простой - но коварный) пример программы. Определим функцию
умножения mult через уже нам известную функция сложения add.
add(Z, y) = y;
add(S(x), y) = S(add(x, y));
mult(Z, y) = Z;
mult(S(x), y) = add(mult(x, y), y);
При этом, натуральные числа 0, 1, 2, 3 представлены в виде
Z, S(Z), S(S(Z)), S(S(S(Z))),… , а определения функций основаны на
следующих свойствах натуральных чисел:
0 + y = y
(x + 1) + y = (x + y) + 1
0 * y = 0
(x + 1) * y = x * y + y
Возьмём начальную конфигурацию mult(a, b) и попробуем построить из неё граф
конфигураций. Получается такое дерево:
Видно, что если пойти по правой ветви этого дерева, то получается такая последовательность конфигураций:
mult(a, b)
add(mult(a1, b), b)
add(add(mult(a2, b), b), b)
add(add(add(mult(a3, b), b), b), b)
Получается так, что ни одна из конфигураций не является “частным случаем”
какой-либо из предыдущих конфигураций! Так получается из-за того, что с каждым
шагом снаружи добавляется новый вызов функции add.
Что делать? Здравый смысл подсказывает, что у всех этих конфигураций всё же есть
общая часть: подвыражение вида mult(ak, b). В частности, можно свести
add(mult(a1, b), b) к mult(a, b) с помощью “обобщения”, использовав
конструкцию let:
let v1 = mult(a1, b), v2 = b in add(v1, v2)
после чего уже можно добавить к графу обратную стрелку от mult(a1, b) к
mult(a, b). В результате получается такой (незаконченный) граф конфигураций:
Cуперкомпилятор SPSC действительно разделывается с этой программой именно
так: mult(a,b)! Правда, результат суперкомпиляции какой-то неинтересный: остаточная программа, по существу, совпадает с исходной… Ну так ведь и
программа такова, что непонятно, что можно было бы сделать с ней интересного?
Вот если бы начальная конфигурация была не mult(a, b), а какого-то более специального вида… Но об этом - позже.
А сейчас попытаемся сформулировать “правило зацикливания”, которое было бы обобщением правила, описанного в заметке Обобщение конфигураций при суперкомпиляции.
Там мы просто проверяли, является ли X2 “частным случаем” X1. А только что
мы столкнулись с ситуацией, когда X2 не является частным случаем X1, но
внутри X2 “зарыто” подвыражение Y, которое является-таки частным случаем
выражения X1. Тогда можно, с помощью конструкции let “вытащить” Y из X2
“на поверхность”:
let v = Y in E
где E{v:=Y} = X2. После этого можно разобрать let на части и добавить обратную
стрелку от Y к X1.
Всегда ли это помогает? Как и следовало ожидать - не всегда. Рассмотрим опять
фунцию сложения add и попробуем просуперкомпилировать конфигурацию начальную
add(a, a). Когда мы суперкомпилировали add(a, b), всё получалось хорошо. Но
в случае add(a, a) различие заключается в том, что переменная a повторяется
2 раза! Казалось бы, ну чего в этом плохого? А вот сейчас и увидим!
В результате прогонки получается такое дерево:
Видно, что порождается такая последовательность конфигураций:
add(a, a)
add(a1, S(a1))
add(a2, S(S(a2)))
add(a3, S(S(S(a3))))
Плохо дело! Конфигурации всё раздуваются и раздуваются! И ни одна из них не
является частным случаем предыдущий. Попробуем, например, найти подстановку,
такую, чтобы add(a, a) совпало c add(a1, S(a1)). По сути, надо решить
уравнение
add(a, a) = add(a1, S(a1))
подобрав для a такую подстановку, чтобы левая часть совпала с правой. Такого значения для a подобрать невозможно. (В этом месте я написал длинное и нудное объяснение, почему невозможно, но потом его вымарал. Как известно, очевидные вещи гораздо труднее объяснять, чем неочевидные. :-))
Чтобы получился конечный граф, нужно сделать обобщение (с помощью всё
той же конструкции let). В данном случае, беда произошла из-за того, что
переменная a входит в add(a, a) два раза. Так сделаем из одной
переменной две разные:
let v1 = a, v2 = a in add(v1, v2)
теперь конфигурация add(a, a) превратилась в add(v1, v2). А как
суперкомпилируется такая конфигурация - мы уже разбирали в заметке
Что такое суперкомпиляция?: получается конечное дерево
конфигураций.
Сразу следует отметить одну тонкость. До сих пор мы делали обобщение таким
способом: сравнивали “верхнюю” конфигурацию X1 с “нижней” конфигурацией X2 и
“обобщали” X2 таким образом, чтобы она совпала с X1 (с точностью до имён
переменных). Например, сравнивали add(a, b) и add(a1, S(b)) и обобщали
add(a1, S(b)) до add(v1, v2). При этом, “прогресс” при построении графа
конфигураций был непрерывным и “поступательным”: к уже существующим узлам
пристраивались дополнительные узлы и стрелки.
Но при сравнении add(a, a) и add(a1, S(a1)) мы столкнулись с принципиально
другим случаем! add(a1, S(a1)) невозможно обобщить до add(a, a)! Конечно,
можно преобразовать add(a1, S(a1)) следующим образом:
let v1 = a1, v2 = S(a1) in add(v1, v2)
При этом, получается, что add(a1, S(a1)) - это частный случай
add(v1, v2). Но беда в том, что add(v1, v2) не является частным
случаем add(a, a) (чего нам хотелось бы добиться). Совсем наоборот! add(a,
a) - это частный случай add(v1, v2), поскольку существует такая подстановка
{v1 := a, v2 := a}, что
add(v1, v2){v1:=a, v2:= a} = add(a, a)
Вот так-то! Абстрактно говоря, сначала встретилась конфигурация X1, а потом -
конфигурация X2. Попробовали обобщить X2 до X1, но выяснилось, что это
невозможно. Единственно, чего можно добиться, так это найти такую конфигурацию
Y, которая является обобщением как по отношению к X1, так и по отношению к
X2. В данном случае:
Вот так-то! Абстрактно говоря, сначала встретилась конфигурация X1, а потом -
конфигурация X2. Попробовали обобщить X2 до X1, но выяснилось, что это
невозможно. Единственно, чего можно добиться, так это найти такую конфигурацию
Y, которая является обобщением как по отношению к X1, так и по отношению к
X2. В данном случае:
add(v1, v2){v1:=a, v2:= a} = add(a, a)
add(v1, v2){v1:=a1, v2:=S(b)} = add(a1, S(b))
Как должен действовать суперкомпилятор в подобных случаях? Наверное, возможны разные варианты… Но, наверное, самое простое решение проблемы таково.
Как мы помним, граф конфигураций - это дерево плюс обратные стрелки. X2
находится в поддереве, подвешенном под X1. Пусть Y - обобщение по отношению
к X1 и X2. Тогда суперкомпилятор уничтожает всё поддерево, подвешенное к
X1 (в том числе - и X2), а узел X1 заменяет на узел вида
let v1 = E1,..., vk = Ek in Y
После чего суперкомпиляция продолжается. Суперкомпилятор SPSC именно так и поступает: add(a, a)!
Однако, новизна ситуации в том, что вместо поступательного построения дерева получается некоторая последовательность попыток: строим поддерево, “разочаровываемся” в нём, уничтожаем его, обобщаем конфигурацию, из которой оно выросло, строим поддерево заново и т.д. Какая-то подозрительная ситуация! А вдруг, этот процесс будет продолжаться бесконечно? Как ни странно, оказывается, если всё делать аккуратно, процесс завершается. Кто сомневается, может проштудировать обстоятельную статью Сёренсена, в которой всё что надо научно доказываются:
Итак, если мы решили, что две конфигурации X1 и X2 - “похожи”, то можно либо
обобщить X2 до X1, либо, на худой конец, построить конфигурацию Y,
являющуюся обобщением по отношению как к X1, так и к X2. Вопрос только в
том, как формализовать само понятие “похожи”? Можно было бы рассматривать
некоторое симметричное отношение “похожести” для конфигураций, но нам нужно не
это. Суперкомпиляция - это имитация “обычных” вычислений. Тем самым, процесс
суперкомпиляции имеет некоторое направление! Поэтому, при сравнении конфигураций
нужно учитывать, какая из конфигураций появилсь раньше, а какая - позже.
А само сравнение конфигураций делается для того, чтобы процесс построения дерева конфигураций (с обратными стрелками, превращающими его в граф) завершался за конечное время.
Вот, допустим, под конфигурацией X1 в дереве висит конфигурация X2. Нам
нужно решить, нужно ли обобщать X2 (или даже X2 вместе с X1), или же дела
идут хорошо, и можно продолжать процесс, приделывая, с помощью прогонки, к X2
новое поддерево.
Здесь возникает конфликт интересов. С одной стороны, суперкомпиляция делается для того, чтобы проанализировать поведение программы “в общем виде”. Каждая конфигурация описывает некоторое множество возможных состояний вычислительного процесса. Если конфигураций в дереве конфигураций много, это означает, что суперкомпилятор разбивает множество состояний на много частей, рассматривает много частных случаев. Стало быть, анализ процесса вычислений - утончённый и глубокий. А если конфигураций мало - это означает, что суперкомпилятор работает “топорно”, и ничего интересного с помощью суперкомпиляции не получается (например, на выходе суперкомпилятора просто вываливается исходная программа).
А обобщение приводит к уменьшению количества рассматриваемых конфигураций. Обобщили - значит свалили два разных множества состояний в одну кучу (и часто даже ещё и что-то лишнее в эту кучу втащили). Результат суперкомпиляции становится менее утончённым и менее интересным.
Таким образом, если не обобщать - дерево конфигураций может получиться бесконечным. А если слишком ретиво обобщать - дерево получится конечным, но неинтересным.
Итак, разумным представляется такой подход: сравниваем X1 и X2. Если X2 на
X1 “непохожа”, то не обобщаем X2 и X1. А если X2 “похожа” на X1, то
надо изучить ситуацию подробнее. Если X2 - “меньше по размерам”, чем X1, то
разумно X2 не обобщать, поскольку количество конфигураций, которые “меньше”,
чем X1 - конечно, и мы можем себе позволить их все перебрать не рискуя тем,
что дерево конфигураций получится бесконечным. (Разумеется, это верно, если
понятие “меньше” определено надлежащим образом.) А вот если X1 и X2 похожи,
и при этом X2 - “больше”, чем X1, то ситуация - опасная, нужно обощать!
Введем для ситуации, когда X1 и X2 “похожи”, но при этом X2 - “больше”,
чем X1, такое обозначение:
X1 <| X2
Как определить такое отношение, разные умные люди думали. Но первыми придумали Хигман (Higman) и Крускал (Kruskal). (Кстати, именно “КрУскал”, а не “КрускАль”, поскольку Kruskal - не француз. Был бы француз, его фамилия писалась бы как “Crouscal”.)
Хигман и Крускал догадались, что определить отношение <| можно гениально
простым способом! А именно, пусть считается, что X1 <| X2, если X2 можно
превратить в X1 стерев в X2 некоторые его части. Сразу убивается два зайца:
X2 можно превратить в X1, кое-что стирая в X2, то кто же
усомнится, что X1 и X2 - “похожи”?X2 можно превратить в X1, кое-что стирая в X2, то это и
означает, что X2 - “толще” или “больше”, чем X1.Хигман применил эту идею к линейным строкам, а Крускал - обобщил для деревьев
(термов, составленных из n-арных конструкторов). А отношение <| научно назвали
“отношением гомеоморфного вложения”, так что, X1 <| X2 читается так: “X1
гомеоморфно вложено в X2”. Ну да, можно считать, что X1 полностью
присутствует в X2, но в “разбавленном” виде. Например, если у нас есть 50
граммов коньяка, и мы доливаем в него 150 граммов водки, можно считать, что в
получившейся смеси 50 граммов коньяка “вложены” в 200 граммов получившейся
смеси. При этом, исходные 50 граммов коняка можно получить обратно, отпив из
стакана 150 граммов водки.
В случае выражений, с которыми работает суперкомпилятор, эти выражения содержат не только конструкторы, но ещё и вызовы функций, и переменные. Ну, вызовы функций ничего принципиально нового не добавляют (поскольку имена конструкторов и имена функций не совпадают). Так что, с вызовами функций можно обращаться так же, как с конструкторами. Но что делать с переменными? Самый простой подход - свалить все переменные в одну кучу и считать, что всем переменным как бы соответствует один нуль-арный конструктор. Есть и более тонкие способы обойтись с переменными, о которых можно прочитать, например, в статье
Рассмотрим, например add(a, b) и add(a1, S(b)). Верно ли, что
add(a, b) <| add(a1, S(b))
? Верно! Подтираем в add(a1, S(b)) конструктор S и получаем
add(a1, b). А это выражение совпадает с add(a, b) с точностью
до имён переменных.
Рассмотрим mult(a, b) и add(mult(a1, b), b). Верно ли, что
mult(a, b) <| add(mult(a1, b), b)
? Верно! Подтираем в add(mult(a1, b), b) вызов функции add с её вторым
аргументом, и получаем mult(a1, b), что совпадает с mult(a, b) с точностью
до имён переменных.
Рассмотрим add(a, a) и add(a1, S(a1)). Верно ли, что
add(a, a) <| add(a1, S(a1))
? Верно! Стираем в add(a1, S(a1)) конструктор S и получаем
add(a1, a1), что совпадает с add(a, a) с точностью до имён переменных.
Можно дать и формальное определение гомеоморфного вложения с помощью индуктивного определения в виде трёх правил:
x <| y для любых переменных x и y.X <| f(E1,...,Ek), если f - имя конструктора или функции, и для
некоторого i выполнено X <| Ei.f(X1,...,Xk) <| f(Y1,...,Yk), если f - имя конструктора или функции,
и для всех i выполнено Xi <| Yi.Правило 2 называется правилом “ныряния” (левое выражение “ныряет” в один из аргументов правого), а привило 3 - правилом “сочетания” (аргументы конструктора/функции с двух сторон “сочетаются браком” друг с другом).
Интересно, что отношение гомеоморфного вложения нашло первое применение не в суперкомпиляции, а в системах переписывания термов:
Некоторое время спустя, Мортен Сёренсен и Роберт Глюк задумчиво сидели в DIKU (что означает Факультет Информатики Копенгагенского Университета) и ломали голову над проблемой сравнения конфигураций. И тут к ним в комнату вошёл Нил Джоунс с какой-то статьёй в руках и спросил “А не это ли вы ищете?”. А в статье было описано отношение гомеоморфного вложения. Оказалось, что именно это и искали… После чего, Сёренсен и Глюк и написали вот эту статью:
Интересно, что отношение <| хорошо работает также в случаях, когда у
суперкомпилятора появляется возможность выполнить какие-то вычисления над
известными частями данных во время суперкомпиляции. Например, вспомним функцию
addAcc, которая выполняет сложение с помощью накапливающего параметра
(аккумулятора):
addAcc(Z, y) = y;
addAcc(S(x), y) = addAcc(x, S(y));
Попробуем просуперкомпилировать выражение addAcc(S(S(a)), b). Как с ним
разделывается суперкомпилятор SPSC? А вот так: addAcc(S(S(a)), b).
На одной из ветвей дерева получается такая последовательность конфигураций:
1. addAcc(S(S(a)), b)
2. addAcc(S(a), S(b))
3. addAcc(a, S(S(b)))
4. addAcc(a1, S(S(S(b))))
Видно, что второй аргумент всё время угрожающе раздувается. Но суперкомпилятор не пугается! Второй аргумент раздувается, но зато первый аргумент уменьшается. Поэтому конфигурация 1 не вложена в конфигурации 2, 3 и 4, а конфигурация 2 - в конфигурации 3 и 4. А вот конфигурация 3 вложена в конфигурацию 4! И при этом 4 является частным случаем 3. Поэтому, 4 обобщается до 3 и в дереве конфигураций создаётся обратная стрелка на конфигурацию 3.
Возникает такой вопрос: а не может ли получиться так, что в процессе прогонки у
нас всё время будут получаться всё время разные конфигурации, которые не буду
вкладываться друг в друга. Оказывается, это не так! Для любой бесконечной
последовательности конфигураций X1, X2, X3,… обязательно найдутся такие
i и j, что i < j и
Xi <| Xj
Соответствующую теорему доказали Хигман и Крускал. Крускал - для случая, когда выражения составлены только из конструкторов. При этом принципиальным является то, что все конструкторы принадлежат конечному множеству. Однако, в суперкомпиляторе SPSC, например, выражения могут содержать ещё и вызовы функций, и переменные. Однако, теорема всё равно применима, поскольку вызовы функций, с синтаксической точки зрения - то же, что и конструкторы (а смысл термов для гомеоморфного вложения безразличен). При этом, в конфигурациях могут появляться только те конструкторы и имена функций, которые присутствуют в исходной программе. Некоторую проблему представляют переменные, ибо переменные-то могут появляться в конфигурациях в неограниченном количестве: суперкомпилятор их генерирует по мере надобности. Как быть? Простейшее решение, свалить все переменных в одну кучу и не различать их между собой. Т.е. считать, что
x <| y
для любых переменных x и y. Вот и получается, что с точки зрения <|
переменная как бы одна-единственная, и её можно воспринимать как
нуль-арный конструктор.
Тут следует упомянуть, что Турчиным был предложен и другой подход к организации обобщения и зацикливания в суперкомпиляторе, не основанный на гомеоморфном вложении конфигураций:
Но об этом надо будет написать отдельно…
Вот, пожалуй, я и рассказал об “азах” суперкомпиляции: прогонке (с построением бесконечного дерева), обобщении и зацикливании (с помощью гомеоморфного вложения) и построении остаточной программы из конечного графа конфигураций.
Можно посмотреть, как всё это реализовано в суперкомпиляторе SPSC, написанном на языке Scala (который, по сути, можно рассматривать как некий синтез Standard ML и Java). Для “учебных” целей был изготовлен “облегчённый” вариант SPSC, SPSC Light, исходные тексты которого выставлены там же.
SPSC Light полностью работоспособен: в нём присутствуют все “принципиально важные” элементы суперкомпилятора, но опущены некоторые неинтересные части (вроде проверки разных контекстных ограничений на входные программы).