Сергей Романенко
18 июня 2009 г.
В заметках
речь шла о том, что метавычисления являются “пародией” на обычные вычисления. А “прогонка” - простейший вариант метавычислений (без обобщений и зацикливаний). В общем случае прогонка порождает бесконечное дерево, которое можно сделать конечным, применяя “обобщение” конфигураций и “зацикливание”.
Сейчас мы постараемся подробнее разобраться, как выглядит прогонка в случае “ленивого” языка с функциями высших порядков. Суперкомпилятор HOSC обрабатывает программы именно на языке такого рода: Higher Order Lazy Language (HLL).
В случае языка HLL, программа имеет следующий вид:
d_1 ... d_N e_0 where f_1 = e_1 ; ... f_N = e_N ;
и содержит следующие части:
d_1 ... d_N - объявления типов для данных, обрабатываемых
программой. (Сейчас эта часть программы нас не очень интересует.)
e_0 - “целевое” выражение. Считается, что исполнение программы
сводится к вычислению e_0. Если e_0 содержит свободные переменные,
то через них в программу передаются исходные данные.
f_1 = e_1 ; ... f_N = e_N ; - определения глобальных функций. Эти
функции могут использоваться внутри целевого выражения e_0.
Вот пример программы:
data Nat = Z | S Nat;
add a b
where
add = \x y -> case x of { Z -> y; S x1 -> S (add x1 y); };
Целевое выражение содержит три свободные переменные a и b, через которые
в программу и передаются исходные данные, а исполнение программы
сводится к тому, что в целевое выражение add a b подставляются значения
переменных a и b, после чего получившееся выражение и вычисляется.
Вот в этом и состоит различие между “обычным” вычислением выражения и “прогонкой”. При обычном вычислении обрабатываемое выражение не может содержать свободные переменные, а при прогонке - может.
Заметим, что в случае вычислений первого порядка (как в суперкомпиляторе SPSC было достаточно сказать, что выражение вообще не содержит переменные (или содержит), а в случае высшего порядка приходится ещё различать свободные и связанные переменные.
Как выполняется такого рода программа, сначала рассмотрим на примере. (И, с точки зрения людей, пишущих на Фортране, такой способ исполнения программы выглядит как чистое извращение. :-) )
Допустим, на вход поступили такие данные:
a = S Z;
b = Z;
Подставляем значения a и b в add a b. Получается
add (S Z) Z
И вот тут-то и начинается самое страшное. В случае языка первого порядка (как в SPSC мы сразу же, за один шаг, преобразовали бы это выражение в выражение
S (add Z Z)
сделав много разных дел:
add.add применить. А для
этого нужно было бы выполнить сопоставление в образцом.Но то, что было в случае “первого порядка” одним большим действием, в случае HLL разбивается на несколько более элементарных действий. Разбиение на мелкие действия хорошо с точки зрения компьютера, с точки зрения тех, кто пишет статьи о суперкомпиляторе, и даёт некоторые дополнительные возможности в процессе преобразований. Но есть и “оборотная сторона медали”! При попытке “прокрутить” какие-то примеры вручную, на бумаге, сразу же оказывается, что граф конфигураций жутко раздувается. Точнее, раздувается не сам граф, как таковой, а конфигурации, находящиеся внутри узлов графа. Что мы сейчас и увидим.
Итак, смотрим на выражение
(add (S Z) Z)
На верхнем уровне - вызов функции add. Хорошо бы его применить к
аргументам. Чтобы сделать это, ищем определение функции add, и
подставляем его вместо имени функции! Таков уж язык высшего порядка. В
случае первого порядка, программа и данные строго разделены, но если мы
объявили, что функции являются “равноправными значениями” (first-class
values), различие между “программой” и “данными” рассеивается как
утренний туман (или, говоря ещё более поэтично, как нежные лепестки
цветка сакуры под весенним ветром). Если число можно подставить, можно и
функцию подставить. Что и делаем. Получается вот такая гадость:
(\x -> (\y -> case x of { Z -> y; S x1 -> S (add x1 y); })) (S Z) Z
Концептуально - ясно и понятно. Но выписывать руками - сущее мучение.
Теперь выражение имеет вид (\v -> e_0 ) e_1, и понятно, что нужно
сделать дальше: применить функцию к аргументу. Т.е. в e_0 заменить все
вхождения v на e_1 . Конкретно, (\x -> ...) применяем к S Z и получаем
(\y -> case S Z of { Z -> y; S x1 -> S (add x1 y); }) Z
Теперь (\y -> ...) применяем к Z. Получается
case S Z of { Z -> Z; S x1 -> S (add x1 Z); }
И вот теперь наступает момент, когда нужно выполнить “сопоставление с
образцом”. Аргумент case-выражения содержит конструктор S на верхнем
уровне. Поэтому, S Z сопоставляется с образцом S x1. При этом,
переменная x1 принимает значение Z. После чего, мы извлекаем из
case-выражения ветвь
S x1 -> S (add x1 Z);
и подставляем в S (add x1 Z) значение переменной x1, т.е. Z. После чего
обрабатываемое выражение принимает вид:
S (add Z Z)
На верхний уровень выражения (из его глубин) выполз конструктор S. Что
делать дальше? В случае “строгого” языка нужно было бы продолжать
вычисления, поскольку в аргументе конструктора остался вызов функции
add. Нужно его вычислять, даже если результат этого вычисления не будет
использован.
Но в случае “ленивого” языка, результат вычисления может выдаваться
потребителю не весь сразу - а частями. В данном случае, “частичный
результат” (в виде конструктора S на верхнем уровне) - налицо. Поэтому,
можно считать, что вычисление “частично закончено”. Но, предположим, что
некий потребитель “съел” верхний конструктор, и захотел использовать
значение его аргумента add Z Z. Ну что же, тогда вычисление
возобновляется, и получается такая последовательность преобразований:
add Z Z
(\x -> (\y -> case x of { Z -> y; S x1 -> S (add x1 y); })) Z Z
(\y -> case Z of { Z -> y; S x1 -> S (add x1 y); }) Z
case Z of { Z -> Z; S x1 -> S (add x1 Z); }
Z
Т.е., “совсем” окончательный результат - S Z.
А теперь наступил момент, когда нужно перейти от “обычных” вычислений к
“метавычислениям”. Что будет, если не подавать в программу исходные
данные, а оставить в целевом выражении переменные, и попытаться
вычислять “прямо так”? Оказывается, что это - не так уж трудно. Начинаем
с выражения add a b. И видим, что несколько первых шагов делаются точно
так же, как и в случае известных исходных данных:
add a b
(\x -> (\y -> case x of { Z -> y; S x1 -> S (add x1 y); })) a b
(\y -> case a of { Z -> y; S x1 -> S (add x1 y); }) b
case a of { Z -> b; S x1 -> S (add x1 b); }
А вот теперь появляется интересная ситуация: в аргументе case-выражения
a, значение которой неизвестно. Как выбрать нужную ветвь в
case-выражении?Единственный выход - действовать “разбором случаев”: рассмотреть
отдельно два случая: a = Z и a = S a1, где a1 - новая переменная,
которая не встречалась в программе. В этот момент линейная
последовательность вычислений превращается в дерево!
Итак, для случая a = Z получается
case Z of { Z -> b; S x1 -> S (add x1 b); }
b
А для случая a = S a1 получается
case S a1 of { Z -> b; S x1 -> S (add x1 b); }
S (add a1 b)
Дальше мы извлекаем из конструктора его аргумент, и начинаем изучать
процесс его вычисления отдельно (как и в случае языка первого порядка).
И видим, что нам повезло: изучать-то нечего, поскольку add a1 b
совпадает (с точностью до имён переменных) с начальной конфигурацией
add a b. Значит, можно сделать зацикливание и получить конечный граф
конфигураций:
Суперкомплятор HOSC именно такой граф и делает: add a b.
Впрочем, этот граф виден, если рассматривать пример через FireFox, ибо для рисования графа используется svg-графика. FireFox рисует эти графы хорошо, а видно ли в других браузерах - не уверен. Кажется, нужно какие-то плагины устанавливать для отрисовки svg-графики…
Итак, пока мы занимались прогонкой, вроде бы никаких особых проблем (по сравнению с “первым порядком”) не возникло. Хотя, если описывать прогонку для HLL аккуратно и со всеми подробностями, то это описание получается довольно занудным. Приходится возиться со всякими понятиями, вроде “слабая головная нормальная форма”, “наблюдаемое”, “редекс”, “контекст”, и т.п. К счастью, при изучении конкретных примеров суперкомпиляции, всего этого можно и не знать, а руководствоваться здравым смыслом и общими принципами, на которых построены ленивые вычисления.
(Но тех, кому интересно, могу порадовать тем, что в работе сейчас находится документ, в котором будут подробно описаны внутренности суперкомпилятора HOSC, в виде формул и всего прочего, что должно быть в “научных” сочинениях.)
Более интересен другой вопрос! Ну хорошо, с прогонкой “высшего порядка” всё, вроде, получается. Но ведь в суперкомпиляторе, кроме прогонки, нужно делать и кое-что ещё: сравнивать конфигурации, строить обобщения конфигураций, проверять, вкладывается ли одна конфигурация в другую или нет?
И здесь возникают кое-какие тонкости, связанные с тем, что в случае “высшего порядка”, переменные в конфигурациях могут быть как свободными, так и связанными (в то время, как в случае “первого порядка”, связанные переменные в конфигурациях не появлялись).
Но это - тема для отдельного разговора…
26 декабря 2009 г
Вот он, обещанный в тексте послания, многострадальный документ, описывающий внутренности HOSC-а!
Это - препринт, выложенный на сайте Института Прикладной математики им.М.В.Келдыша:
Klyuchnikov I.G. Supercompiler HOSC 1.0: under the hood KIAM Preprint No. 63, Moscow, 2009
The paper describes the internal structure of HOSC, an experimental supercompiler dealing with programs written in a higher-order functional language. A detailed and formal account is given of the concepts and algorithms the supercompiler is based upon.