Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Интринсики и зацикливание специализатора #320

Closed
Mazdaywik opened this issue Jul 19, 2020 · 7 comments · Fixed by #328
Closed

Интринсики и зацикливание специализатора #320

Mazdaywik opened this issue Jul 19, 2020 · 7 comments · Fixed by #328
Assignees
Labels

Comments

@Mazdaywik
Copy link
Member

Mazdaywik commented Jul 19, 2020

UPD 20.07.2020 21:58: Проблема в стартовом топике решена (в базовом варианте). Но обнаружилась более интересная проблема — см. комментарий #320 (comment).

Проблема

Целью дипломных проектов этого года отчасти было обеспечение безопасности оптимизации. @Kaelena стремилась создать безопасную разметку для оптимизируемых функций (#252), @koshelevandrey делал специализацию безопасной (#253). Но всё испортил диплом @Cstyler’а, который добавил в компилятор интринсики (#260).

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

$ENTRY Len {
  e.X = <DoLen 0 e.X>;
}

DoLen {
  s.Acc t._ e.X = <DoLen <+ s.Acc 1> e.X>;
  s.Acc /* empty */ = s.Acc;
}

Это типичная функция на Рефале-5, в данном случае она подсчитывает длину строки. Подсчитывает длину с использованием аккумулятора.

Но, с включёнными оптимизациями -OAiS получается шляпа. Автоматическая разметка функции DoLen назначает шаблон

$SPEC DoLen s.STAT e.dyn;

Да, это врождённая проблема автоматической разметки — делать аккумулятор статическим параметром. И с этим уже ничего не поделаешь.

А далее срабатывает взаимодействие специализации и интринсиков.

Отношение Хигмана-Крускала, используемое для остановки специализатора, прерывает бесконечные цепочки только в конечном алфавите символов. И без интринсиков этот механизм работает как часы: число символов в программе конечно, соответствует тому, которые были явно заданы в программе.

Но включение в компилятор интринсиков делает множество символов бесконечным — новые символы будут порождаться в процессе выполнения программы. А тут уже отношение Хигмана-Крускала бессильно.

Для примера выше будет порождаться следующая цепочка специализаций:

  $ENTRY Len {
    e.X#1 = <DoLen@1 e.X#1>;
  }
  …
  DoLen@1 {
    t._#1 e.X#1 = <DoLen@2 e.X#1>;
  
    /* empty */ = 0;
  
    e.dyn#0 = <DoLen@0 0 e.dyn#0>;
  }
  
  DoLen@2 {
    t._#1 e.X#1 = <DoLen@3 e.X#1>;
  
    /* empty */ = 1;
  
    e.dyn#0 = <DoLen@0 1 e.dyn#0>;
  }
  
  DoLen@3 {
    t._#1 e.X#1 = <DoLen@4 e.X#1>;
  
    /* empty */ = 2;
  
    e.dyn#0 = <DoLen@0 2 e.dyn#0>;
  }
  
  DoLen@4 {
    t._#1 e.X#1 = <DoLen@5 e.X#1>;
  
    /* empty */ = 3;
  
    e.dyn#0 = <DoLen@0 3 e.dyn#0>;
  }
  …

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

Алфавит чисел в данной реализации формально конечен, т.к. числа не могут превышать 4294967295. Но на практике это ничего не даёт, т.к. (а) на 32-разрядной платформе не хватит адресного пространства, чтобы их перебрать, на 64-разрядной хватит, но не хватит памяти компьютера, (б) есть встроенная функция Implode, которой можно порождать бесконечное количество идентификаторов.

Решение проблемы

Проблема, как это часто бывает, имеет несколько решений. В данном случае можно рассмотреть два: частное и общее.

Есть также третье, экзотическое решение, рассматривать символы как составные объекты, последовательности байт или бит, но это не серьёзно.

Частное решение

Интринсики могут порождать бесконечное количество новых символов только трёх типов: литеры (Chr), числа (арифметика) и идентификаторы (Implode, Implode_Ext).

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

Поэтому предлагается при проверке отношения Хигмана-Крускала значения символов-чисел просто не учитывать. На данный момент при проверке отношения сигнатуры нормируются — индексы переменных в них стираются. Предлагается точно также стирать и значения символов-чисел.

Аналогичное решение используется в экспериментальном суперкомпиляторе SCP4 (см. книжку про него, стр. 40) — любые два числа в упрощающем отношении являются похожими.

Преимущества и недостатки:

  • ✔ Осуществимо минимальными правками кодовой базы.
  • ✔ Решает проблему в большинстве практически значимых случаев.
  • ❌ Решение только для частного случая. Можно подобрать тест, где программа будет зацикливаться.
  • ❌ Может происходить избыточное переобобщение. Например, неопытный программист может использовать число как флаг, по значению флага программа специализирована не будет.

Общее решение

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

При прогонке статические и динамические символы с равными атрибутами считаются равными. Разница возникает только при вычислении отношения Хигмана-Крускала — для динамических символов стираются их значения.

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

Преимущества и недостатки:

  • ✔ Решение общего случая — нельзя построить программу, на которой оптимизатор будет зацикливаться.
  • ✔ Не будет происходить переобобщение, если в качестве смыслоразличающих символов используются числа.
  • ❌ Потребует масштабных правок кодовой базы, в частности, алгоритма обобщённого сопоставления с образцом. Процесс правок чреват ошибками.

Вывод

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

@Mazdaywik
Copy link
Member Author

Mazdaywik commented Jul 19, 2020

На самом деле, об этой проблеме я знал давно и планировал написать эту заявку тоже давно. Но выяснилось, что автоматическая разметка почему-то не выводит формат этой функции, поэтому рабочий пример написать не получилось. Поэтому пришлось решать проблему с расстановкой меток (Spec …) (#317).

Также я сначала думал, что проблема сугубо теоретическая. Но она оказалась практической — см. #319 (comment).

@Mazdaywik
Copy link
Member Author

Mazdaywik commented Jul 20, 2020

Новая проблема

Как оказалось, предыдущий коммит решает только одну из нескольких проблем.

Зацикливание остаётся, но его причина оказывается гораздо тоньше. Рассмотрим ту же функцию:

$ENTRY Len {
  e.X = <DoLen 0 e.X>;
}

DoLen {
  s.Acc t._ e.X = <DoLen <+ s.Acc 1> e.X>;
  s.Acc /* empty */ = s.Acc;
}

Оптимизация представляет собой три вложенных цикла:

  • Внешний цикл выполняет разметку и оптимизирует дерево, пока оно не перестанет меняться.
  • Средний цикл выполняет специализацию функций, пока дерево не перестанет меняться.
  • Внутренний цикл выполняет прогонку вызовов и раскрытия интринсиков, пока дерево не перестанет меняться.

Ранее зацикливание происходило в среднем цикле — на каждой итерации строились новые экземпляры один за другим. Автоматическая разметка нужна была только для того, чтобы назначить сигнатуру для DoLen.

Проблему зацикливания в среднем цикле мы решили, но возникло зацикливание во внешнем цикле!

Посмотрим, что получается в логе:

Mon Jul 20 20:53:24 2020: AST of file Pass 100 (before Auto markup):
  
  $ENTRY Len {
    e.X#1 = <DoLen 0 e.X#1>;
  }
  
  DoLen {
    s.Acc#1 t._#1 e.X#1 = <DoLen <Add s.Acc#1 1> e.X#1>;
  
    s.Acc#1 = s.Acc#1;
  }

$EXTERN Add;
$INTRINSIC Add;

Запуск:

..\bin\rlc-core test.ref -OADiS --log=test.log -C

Функция DoLen проспециализируется по значению аккумулятора. Затем просвистит свисток (т.к. 0 и 1 теперь неразличимы) и будет выполнено обобщение. Получится такая программа:

Mon Jul 20 20:53:24 2020: AST of file Pass 88 (before Auto markup):
  (SpecInfo ...)
  (DriveInfo ...)
  
  $ENTRY Len {
    e.X#1 = <DoLen@1 e.X#1>;
  }
  
  DoLen {
    s.Acc#1 t._#1 e.X#1 = <DoLen <Add s.Acc#1 1> e.X#1>;
  
    s.Acc#1 = s.Acc#1;
  }
  
  Len@0 {
  }
  
  DoLen@0 {
  }
  
  DoLen@1 {
    t._#1 e.X#1 = <DoLen 1 e.X#1>;
  
    /* empty */ = 0;
  
    e.dyn#0 = <DoLen@0 0 e.dyn#0>;
  }
  
Mon Jul 20 20:53:24 2020: New $DRIVE function: DoLen@0
Mon Jul 20 20:53:24 2020: New $DRIVE function: DoLen@1

Вызов <DoLen 1 e.X#1> похож на исходный вызов <DoLen 1 e.X#1>, их обобщение имеет вид <DoLen s e>, т.е. имеет тривиальную сигнатуру. А для тривиальной сигнатуры новый экземпляр не строится.

Функция DoLen@1 в итоге получается нерекурсивной — это всего лишь развёрнутый виток цикла. И из-за того, что функция нерекурсивная, авторазметка помечает её для прогонки. И она прогоняется!

Mon Jul 20 20:53:24 2020: AST of file Pass 86 (before Drive):
  (DriveInfo ...)
  (SpecInfo ...)
  
  $ENTRY Len {
    t.#0 e.#0 = <DoLen 1 e.#0>;
  
    /* empty */ = 0;
  
    e.X#1 = <DoLen@0 0 e.X#1>;
  }

...

Для вызова <DoLen 1 e.#0> строится новый экземпляр.

А почему бы ему и не построиться? Внутри DoLen@1 он построиться не мог, т.к. с DoLen@1 ассоциирована история. А у функции Len нет истории! И специализироваться функции ничего не мешает.

Следующие шаги понятны:

Mon Jul 20 20:53:24 2020: AST of file Pass 76 (before Auto markup):
  (SpecInfo ...)
  (DriveInfo ...)
  
  $ENTRY Len {
    t.#0 e.#0 = <DoLen@2 e.#0>;
  
    /* empty */ = 0;
  
    e.X#1 = <DoLen@0 0 e.X#1>;
  }
  
  …
  
  DoLen@2 {
    t._#1 e.X#1 = <DoLen 2 e.X#1>;
  
    /* empty */ = 1;
  
    e.dyn#0 = <DoLen@0 1 e.dyn#0>;
  }
  
Mon Jul 20 20:53:24 2020: New $DRIVE function: DoLen@0
Mon Jul 20 20:53:24 2020: New $DRIVE function: DoLen@2
Mon Jul 20 20:53:24 2020: AST of file Pass 64 (before Auto markup):
  (SpecInfo ...)
  (DriveInfo ...)
  
  $ENTRY Len {
    t.#0 t.0#0 e.0#0 = <DoLen@3 e.0#0>;
  
    t.#0 = 1;
  
    t.#0 e.#0 = <DoLen@0 1 e.#0>;
  
    /* empty */ = 0;
  
    e.X#1 = <DoLen@0 0 e.X#1>;
  }
Mon Jul 20 20:53:24 2020: AST of file Pass 52 (before Auto markup):
  (SpecInfo ...)
  (DriveInfo ...)
  
  $ENTRY Len {
    t.#0 t.0#0 t.1#0 e.#0 = <DoLen@4 e.#0>;
  
    t.#0 t.0#0 = 2;
  
    t.#0 t.0#0 e.0#0 = <DoLen@0 2 e.0#0>;
  
    t.#0 = 1;
  
    t.#0 e.#0 = <DoLen@0 1 e.#0>;
  
    /* empty */ = 0;
  
    e.X#1 = <DoLen@0 0 e.X#1>;
  }

Вот такая вот фигня.

Возможные решения

Основное решение

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

Поэтому, чтобы избегать избыточных специализаций, историю нужно сохранять.

Предлагается при прогонке историю прогоняемой функции «пришивать» к вызывающей. В данном случае, историю функции DoLen@1 пришивать к функции Len. В этом случае вызов уже прогоняться не будет и компилятор не зациклится.

Но что делать, если осуществляется прогонка вызова одного экземпляра в другом или в одной функции прогоняются два разных экземпляра с двумя разными историями? Варианты:

  • Допускать несколько историй у одной функции. Искать вложение во всех — в какой найдётся раньше.
  • При прогонке забывать предыдущую историю у функции, заменяя на новую.
  • Сохранять историю наибольшей длины.

Я пока не знаю, какой вариант выбрать.

Другие возможные решения

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

Или как-то помечать вызовы, не предназначенные для последующей прогонки. Например, что-то вроде (ColdCallBrackets …), которые запрещают прогонку.

@Mazdaywik
Copy link
Member Author

Вариант, что делать с несколькими историями при прогонке

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

Поэтому предлагается четвёртый вариант:

  • Конкатенировать истории с удалением дубликатов. В принципе, дубликаты можно не удалять, но с удалением красивее.

Конечно, при таком подходе возможно переобобщение и ложные срабатывания, но (а) вероятность представляется небольшой, (б) исключается зацикливание.

Детали решения проблемы

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

  • Функция, осуществляющая прогонку, OptSentence-MakeSubstitution, возвращает, помимо прочего, ноль или одну новую функцию (речь идёт о функции Func*n), в виде e-переменной. Туда можно добавлять узел дерева
    (DrivenCallOf (e.Call) In (e.Function))
    
  • Специализатор такие записки «подбирает» и объединяет истории.
  • Устранять дубликаты узлов (DrivenCallOf …) не требуется, т.к. при объединении историй дубликаты устраняются.

Mazdaywik added a commit that referenced this issue Jul 22, 2020
• Оформление кода.
• Длинные строки.

На самом деле там нужно много чего переделывать. Здесь я поправил участок
кода, с которым нужно работать в рамках #320 и длинные строки, чтобы
не появлялась горизонтальная прокрутка.
Mazdaywik added a commit that referenced this issue Jul 22, 2020
• Изменены описания типов функций в комментариях.
• Переименованы переменные.
@Mazdaywik
Copy link
Member Author

Mazdaywik commented Jul 22, 2020

Проблема не решена

Объединение истории при прогонке решает проблему с функцией DoLen, описанной выше. Но стоит эту функцию чуть-чуть изменить:

$ENTRY Len {
  e.X = <DoLen '*' 0 e.X>;
}

DoLen {
  s._ s.Acc t._ e.X = <DoLen '*' <+ s.Acc 1> e.X>;
  s._ s.Acc /* empty */ = s.Acc;
}

$EXTERN Add;
$INTRINSIC Add;

как всё летит к чертям:

Много фрагментов из лога

В листингах удалены пустые строки между предложениями для наглядности.

Tue Jul 21 15:00:07 2020: AST of file Pass 100 (before Auto markup):
  
  $ENTRY Len {
    e.X#1 = <DoLen '*' 0 e.X#1>;
  }
  
  DoLen {
    s._#1 s.Acc#1 t._#1 e.X#1 = <DoLen '*' <Add s.Acc#1 1> e.X#1>;
    s._#1 s.Acc#1 = s.Acc#1;
  }
  
  $INTRINSIC Add;
  $ENTRY * Len * {
    e.X#1 = <DoLen@1 e.X#1>;
  }
  
  * DoLen * {
    s._#1 s.Acc#1 t._#1 e.X#1 = <DoLen '*' <Add s.Acc#1 1> e.X#1>;
    s._#1 s.Acc#1 = s.Acc#1;
  }
  
  …
  
  DoLen@1 {
    t._#1 e.X#1 = <DoLen '*' <Add 0 1> e.X#1>;
    /* empty */ = 0;
    e.dyn#0 = <DoLen@0 '*' 0 e.dyn#0>;
  }
Tue Jul 21 15:00:07 2020: AST of file Pass 91 (before Drive):
  (SpecInfo ...)
  (DriveInfo ...)
  
  $ENTRY * Len * {
    e.X#1 = <DoLen@1 e.X#1>;
  }
  
  * DoLen * {
    s._#1 s.Acc#1 t._#1 e.X#1 = <DoLen '*' <Add s.Acc#1 1> e.X#1>;
    s._#1 s.Acc#1 = s.Acc#1;
  }
  
  …
  
  * DoLen@1 * {
    t._#1 e.X#1 = <DoLen@2 1 e.X#1>;
    /* empty */ = 0;
    e.dyn#0 = <DoLen@0 '*' 0 e.dyn#0>;
  }
  
  DoLen@2 {
    s.X#0 t._#1 e.X#1 = <DoLen '*' <Add s.X#0 1> e.X#1>;
    s.X#0 = s.X#0;
    s.X#0 e.dyn#0 = <DoLen@0 '*' s.X#0 e.dyn#0>;
  }
Tue Jul 21 15:00:07 2020: AST of file Pass 84 (before Auto markup):
  (SpecInfo ...)
  (DriveInfo ...)
  
  $ENTRY Len {
    e.X#1 = <DoLen@1 e.X#1>;
  }
  
  DoLen {
    s._#1 s.Acc#1 t._#1 e.X#1 = <DoLen '*' <Add s.Acc#1 1> e.X#1>;
    s._#1 s.Acc#1 = s.Acc#1;
  }
  
  Len@0 {
  }
  
  DoLen@0 {
  }
  
  DoLen@1 {
    t._#1 e.X#1 = <DoLen@2 1 e.X#1>;
    /* empty */ = 0;
    e.dyn#0 = <DoLen@0 '*' 0 e.dyn#0>;
  }
  
  DoLen@2 {
    s.X#0 t._#1 e.X#1 = <DoLen '*' <Add s.X#0 1> e.X#1>;
    s.X#0 = s.X#0;
    s.X#0 e.dyn#0 = <DoLen@0 '*' s.X#0 e.dyn#0>;
  }
  
Tue Jul 21 15:00:07 2020: New $DRIVE function: DoLen@0
Tue Jul 21 15:00:07 2020: New $DRIVE function: DoLen@2
Tue Jul 21 15:00:07 2020: New $DRIVE function: DoLen@1
Tue Jul 21 15:00:07 2020: AST of file Pass 82 (before Drive):
  (DriveInfo ...)
  (SpecInfo ...)
  
  $ENTRY Len {
    t.#0 e.#0 = <DoLen@2 1 e.#0>;
    /* empty */ = 0;
    e.X#1 = <DoLen@0 '*' 0 e.X#1>;
  }
  
  …
  
  DoLen@2 {
    s.X#0 t._#1 e.X#1 = <DoLen '*' <* &Add s.X#0 1*> e.X#1>;
    s.X#0 = s.X#0;
    s.X#0 e.dyn#0 = <* &DoLen@0 '*' s.X#0 e.dyn#0*>;
  }
Tue Jul 21 15:00:07 2020: AST of file Pass 81 (before Drive):
  (DriveInfo ...)
  (SpecInfo ...)
  
  $ENTRY Len {
    t.#0 t.0#0 e.0#0 = <DoLen '*' <Add 1 1> e.0#0>;
    t.#0 = 1;
    t.#0 e.#0 = <DoLen@0 '*' 1 e.#0>;
    /* empty */ = 0;
    e.X#1 = <* &DoLen@0 '*' 0 e.X#1*>;
  }
Tue Jul 21 15:00:07 2020: AST of file Pass 78 (before Spec):
  (DriveInfo ...)
  (SpecInfo ...)
  
  $ENTRY Len {
    t.#0 t.0#0 e.0#0 = <DoLen '*' 2 e.0#0>;
    t.#0 = 1;
    t.#0 e.#0 = <DoLen@0 '*' 1 e.#0>;
    /* empty */ = 0;
    e.X#1 = <DoLen@0 '*' 0 e.X#1>;
  }
Tue Jul 21 15:00:07 2020: AST of file Pass 77 (before Drive):
  (SpecInfo ...)
  (DriveInfo ...)
  
  $ENTRY * Len * {
    t.#0 t.0#0 e.0#0 = <DoLen@2 2 e.0#0>;
    t.#0 = 1;
    t.#0 e.#0 = <DoLen@0 '*' 1 e.#0>;
    /* empty */ = 0;
    e.X#1 = <DoLen@0 '*' 0 e.X#1>;
  }

Дальше — понятно, DoLen@2 опять встроится, вызов DoLen проспециализируется и сведётся к новому DoLen@2:

Tue Jul 21 15:00:07 2020: AST of file Pass 74 (before Auto markup):
  (SpecInfo ...)
  (DriveInfo ...)
  
  $ENTRY Len {
    t.#0 t.0#0 e.0#0 = <DoLen@2 2 e.0#0>;
    t.#0 = 1;
    t.#0 e.#0 = <DoLen@0 '*' 1 e.#0>;
    /* empty */ = 0;
    e.X#1 = <DoLen@0 '*' 0 e.X#1>;
  }
Tue Jul 21 15:00:07 2020: AST of file Pass 65 (before Auto markup):
  (SpecInfo ...)
  (DriveInfo ...)
  
  $ENTRY Len {
    t.#0 t.0#0 t.1#0 e.#0 = <DoLen@2 3 e.#0>;
    t.#0 t.0#0 = 2;
    t.#0 t.0#0 e.0#0 = <DoLen@0 '*' 2 e.0#0>;
    t.#0 = 1;
    t.#0 e.#0 = <DoLen@0 '*' 1 e.#0>;
    /* empty */ = 0;
    e.X#1 = <DoLen@0 '*' 0 e.X#1>;
  }
Tue Jul 21 15:00:07 2020: AST of file Pass 56 (before Auto markup):
  (SpecInfo ...)
  (DriveInfo ...)
  
  $ENTRY Len {
    t.#0 t.0#0 t.1#0 t.2#0 e.0#0 = <DoLen@2 4 e.0#0>;
    t.#0 t.0#0 t.1#0 = 3;
    t.#0 t.0#0 t.1#0 e.#0 = <DoLen@0 '*' 3 e.#0>;
    t.#0 t.0#0 = 2;
    t.#0 t.0#0 e.0#0 = <DoLen@0 '*' 2 e.0#0>;
    t.#0 = 1;
    t.#0 e.#0 = <DoLen@0 '*' 1 e.#0>;
    /* empty */ = 0;
    e.X#1 = <DoLen@0 '*' 0 e.X#1>;
  }
Tue Jul 21 15:00:07 2020: AST of file Pass 47 (before Auto markup):
  (SpecInfo ...)
  (DriveInfo ...)
  
  $ENTRY Len {
    t.#0 t.0#0 t.1#0 t.2#0 t.3#0 e.#0 = <DoLen@2 5 e.#0>;
    t.#0 t.0#0 t.1#0 t.2#0 = 4;
    t.#0 t.0#0 t.1#0 t.2#0 e.0#0 = <DoLen@0 '*' 4 e.0#0>;
    t.#0 t.0#0 t.1#0 = 3;
    t.#0 t.0#0 t.1#0 e.#0 = <DoLen@0 '*' 3 e.#0>;
    t.#0 t.0#0 = 2;
    t.#0 t.0#0 e.0#0 = <DoLen@0 '*' 2 e.0#0>;
    t.#0 = 1;
    t.#0 e.#0 = <DoLen@0 '*' 1 e.#0>;
    /* empty */ = 0;
    e.X#1 = <DoLen@0 '*' 0 e.X#1>;
  }

Проблема тут возникает из-за того, что функция DoLen@2 не профакторизована до конца. Вызов

<DoLen '*' <Add s.X#0 1> e.X#1>

не проспециализирован, т.к. аргумент не соответствует формату. Формат s.S1 s.S2 e.d, аргумент — '*' e.Call e.X#1. А функция Add не может быть вычислена до тех пор, пока в её аргументе есть переменные. Из-за этого функция оказывается нерекурсивной, встраивается, функция Add вычисляется и вызов <DoLen '*' 〈число〉 e.#0> снова может проспециализироваться.

В истории функции Len уже есть сигнатура <DoLen '*' 0 e._>, сигнатура специализируемого вызова <DoLen '*' 〈число〉 e._>, срабатывает свисток, происходит обобщение, а сигнатуре <DoLen '*' s._ e._> соответствует экземпляр DoLen@2.

Таким образом, можно назвать следующие причины проблемы:

  • перестройка снизу, что разворачивает один виток цикла,
  • не до конца профакторизованные вызовы,
  • недовычисленный вызов Add,
  • повторный вызов специализации.
Про не до конца профакторизованные вызовы
$ENTRY Len {
  e.X = <DoLen '*' (0) e.X>;
}

DoLen {
  s._ (e.Acc) t._ e.X = <DoLen '*' (<+ (e.Acc) 1>) e.X>;
  s._ (e.Acc) /* empty */ = e.Acc;
}

$EXTERN Add;
$INTRINSIC Add;

В этой программе все вызовы DoLen полностью профакторизуются — в них не останется непроспециализированных вызовов.

Wed Jul 22 13:32:39 2020: AST of file Pass 100 (before Auto markup):
  
  $ENTRY Len {
    e.X#1 = <DoLen '*' (0) e.X#1>;
  }
  
  DoLen {
    s._#1 (e.Acc#1) t._#1 e.X#1 = <DoLen '*' (<Add (e.Acc#1) 1>) e.X#1>;
    s._#1 (e.Acc#1) = e.Acc#1;
  }
  
  $INTRINSIC Add;
Wed Jul 22 13:32:39 2020: New $INTRINSIC function: Add
Wed Jul 22 13:32:39 2020: New $SPEC function: Len e.STAT#0
Wed Jul 22 13:32:39 2020: New $SPEC function: DoLen s.STAT#0 (e.STAT#0) e.dyn#1
Wed Jul 22 13:32:39 2020: AST of file Pass 99 (before Drive):
Wed Jul 22 13:32:39 2020: AST of file Pass 84 (before Auto markup):
  (SpecInfo ...)
  (DriveInfo ...)
  
  $ENTRY Len {
    e.X#1 = <DoLen@1 e.X#1>;
  }
  
  DoLen {
    s._#1 (e.Acc#1) t._#1 e.X#1 = <DoLen@2 (<Add (e.Acc#1) 1>) e.X#1>;
    s._#1 (e.Acc#1) = e.Acc#1;
  }
  
  Len@0 { }
  DoLen@0 { }
  
  DoLen@1 {
    t._#1 e.X#1 = <DoLen@3 1 e.X#1>;
    /* empty */ = 0;
    e.dyn#1 = <DoLen@0 '*' (0) e.dyn#1>;
  }
  
  DoLen@2 {
    (e.Call#0) t._#1 e.X#1 = <DoLen@2 (<Add (e.Call#0) 1>) e.X#1>;
    (e.Call#0) = e.Call#0;
    (e.Call#0) e.dyn#1 = <DoLen@0 '*' (e.Call#0) e.dyn#1>;
  }
  
  DoLen@3 {
    s.X#0 t._#1 e.X#1 = <DoLen@2 (<Add (s.X#0) 1>) e.X#1>;
    s.X#0 = s.X#0;
    s.X#0 e.dyn#1 = <DoLen@0 '*' (s.X#0) e.dyn#1>;
  }
  
Wed Jul 22 13:32:39 2020: New $DRIVE function: DoLen@0
Wed Jul 22 13:32:39 2020: New $DRIVE function: DoLen@3
Wed Jul 22 13:32:39 2020: New $DRIVE function: DoLen@1
Wed Jul 22 13:32:39 2020: AST of file Pass 74 (before Auto markup):
  (SpecInfo ...)
  (DriveInfo ...)
  
  $ENTRY Len {
    t.#0 t.0#0 e.0#0 = <DoLen@2 (2) e.0#0>;
    t.#0 = 1;
    t.#0 e.#0 = <DoLen@0 '*' (1) e.#0>;
    /* empty */ = 0;
    e.X#1 = <DoLen@0 '*' (0) e.X#1>;
  }
  
  DoLen { … }
  Len@0 { }
  DoLen@0 { }
  
  DoLen@1 {
    t._#1 t.#0 e.#0 = <DoLen@2 (2) e.#0>;
    t._#1 = 1;
    t._#1 e.X#1 = <DoLen@0 '*' (1) e.X#1>;
    /* empty */ = 0;
    e.dyn#1 = <DoLen@0 '*' (0) e.dyn#1>;
  }
  
  DoLen@2 {
    (e.Call#0) t._#1 e.X#1 = <DoLen@2 (<Add (e.Call#0) 1>) e.X#1>;
    (e.Call#0) = e.Call#0;
    (e.Call#0) e.dyn#1 = <DoLen@0 '*' (e.Call#0) e.dyn#1>;
  }
  
  DoLen@3 {
    s.X#0 t._#1 e.X#1 = <DoLen@2 (<Add (s.X#0) 1>) e.X#1>;
    s.X#0 = s.X#0;
    s.X#0 e.dyn#1 = <DoLen@0 '*' (s.X#0) e.dyn#1>;
  }

И это неподвижная точка. Почему так произошло? Потому что экземпляры повторно не специализируются — автоматической разметке запрещено назначать им метки $SPEC.

К слову, интринсики тут не причём. Проблему можно воспроизвести и с $INLINE-функциями:

Листинги
$ENTRY Len {
  e.X = <DoLen '*' o e.X>;
}

DoLen {
  s._ t.Acc t._ e.X = <DoLen '*' <add t.Acc (o)> e.X>;
  s._ t.Acc /* empty */ = t.Acc;
}

$INLINE add;
$SPEC add t.x t.y;

add {
  s._ t.y = t.y;
  (t.x) t.y = <add t.x (t.y)>;
}

В логе имеем тоже самое:

Wed Jul 22 12:41:51 2020: AST of file Pass 50 (before Auto markup):
  (SpecInfo ...)
  (DriveInfo ...)
  
  $ENTRY Len {
    t.#0 t.0#0 t.1#0 t.2#0 e.0#0 = <DoLen@2 ((((o)))) e.0#0>;
    t.#0 t.0#0 t.1#0 = (((o)));
    t.#0 t.0#0 t.1#0 e.#0 = <DoLen@0 '*' (((o))) e.#0>;
    t.#0 t.0#0 = ((o));
    t.#0 t.0#0 e.0#0 = <DoLen@0 '*' ((o)) e.0#0>;
    t.#0 = (o);
    t.#0 e.#0 = <DoLen@0 '*' (o) e.#0>;
    /* empty */ = o;
    e.X#1 = <DoLen@0 '*' o e.X#1>;
  }

Что делать?

А вот не очевидно.

В чём смысл трёх вложенных циклов?

Сначала циклов было два — внутренний для прогонки и внешний для специализации, оба работали до неподвижной точки.

Смысл был в чём? Функция может быть одновременно помечена как специализируемая и как прогоняемая. Если её вызов сначала специализировать, то прогнать далее уже не получится. Поэтому сначала прогоняем-встраиваем все те вызовы, которые можно вычислить на этапе компиляции. При правильной разметке этот процесс конечен.

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

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

Внешний цикл выполняется до тех пор, пока дерево не перестанет меняться, не перестанут создаваться новые экземпляры.

Более полное обоснование можно найти в #263.

У автоматической разметки (#252) две основные цели:

  • оптимизация программ без разметки, например, программ для классического Рефала-5,
  • прогонка «транзитных» экземпляров, построенных при специализации программ.

Для достижения первой цели разметку нужно запускать до проходов оптимизаций. Для достижения второй цели нужно выполнить разметку после циклов оптимизаций, разметить прогоняемые функции и их прогнать.

Т.е. оптимизация с разметкой должна выполняться в следующем порядке:

  • Начальная разметка.
  • Цикл специализации до неподвижной точки.
    • А в нём цикл прогонки до неподвижной точки.
  • Повторная разметка (для экземпляров).
  • Цикл прогонки до неподвижной точки.

Это похоже на две итерации некоего цикла, только на второй итерации не выполняется специализация. Поэтому я по аналогии с другими оптимизациями просто решил сделать ещё один цикл снаружи:

  • Разметка до неподвижной точки.
    • А в ней специализация до неподвижной точки.
      • А в ней прогонка до неподвижной точки.

Т.е. как бы устранил дублирование кода. Но, оказалось, что это приводит к зацикливанию.

Попробовал проблему решить сохранением истории при прогонке — решается лишь частично. Причины проблемы описаны выше по тексту (повторюсь):

Таким образом, можно назвать следующие причины проблемы:

  • перестройка снизу, что разворачивает один виток цикла,
  • не до конца профакторизованные вызовы,
  • недовычисленный вызов Add,
  • повторный вызов специализации.

Если суперкомпилятор выполняет перестройку сверху, то его можно сделать идемпотентным, или хотя бы достигающим неподвижной точки — остаточная программа будет идентична исходной (с точностью до имён переменных).

В случае перестройки снизу суперкомпилятор идемпотентным не будет иметь неподвижной точки — его применение к остаточной программе будет осуществлять развёртку ещё одного витка. (Для такого суперкомпилятора можно применять «гиперцикл Абрамова» — повторный вызов с базисными конфигурациями на входе. Но здесь нас это не интересует.)

Оптимизатор Рефала-5λ, вернее цикл «многократная прогонка + специализация» эквивалентен ограниченной суперкомпиляции, причём осуществляющей перестройку снизу. Поэтому он не идемпотентен не имеет неподвижной точки по определению.

И что же делать в итоге?

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

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

@Mazdaywik
Copy link
Member Author

Чтобы разорвать цепь, достаточно разрушить только одно звено.

Таким образом, можно назвать следующие причины проблемы:

  • перестройка снизу, что разворачивает один виток цикла,
  • не до конца профакторизованные вызовы,
  • недовычисленный вызов Add,
  • повторный вызов специализации.

В предыдущем комментарии предлагается избавиться от повторного вызова специализации. Но можно пойти иначе — сделать перестройку сверху!

Перестройка сверху и снизу

Что такое перестройка сверху и снизу в суперкомпиляции?

Допустим у нас есть путь в графе вида

C1 → … → Ci → … → Cj

и конфигурация Ci похожа на конфигурацию Cj — надо зацикливаться.

Если

gen(Ci, Cj) ≡ Ci,   Cj = Ci/Sj

где Sj — подстановка, то всё просто — зацикливаемся в Ci:

C1 → … → Ci → … → Cj = Ci(Sj)

Если

gen(Ci, Cj) = C*,   Ci = C*/Si,   Cj = C*/Sj

то нужно выполнять перестройку.

Перестройка снизу:

C1 → … → Ci → … → Cj = C*(Sj) → C*

Нижняя конфигурация обобщается до C* и дальше уже граф растёт от C*.

Перестройка сверху:

C1 → … → Ci = C*(Si) → C*

Кусок графа, росший от Ci отбрасывается, конфигурация обобщается до C* и развивается уже она.

При перестройке снизу остаётся развёрнутый виток цикла между Ci и Cj, при перестройке сверху он отсекается.

Реализация перестройки сверху в Рефале-5λ

Вместо графа суперкомпиляции у нас граф экземпляров специализированных функций, поэтому Ci, Cj, C* — это не конфигурации, а сигнатуры экземпляров.

Реализация перестройки снизу проста: вычисляем обобщение C*, вычисляем подстановку, переводящую C* в Cj и подставляем вызов экземпляра C*. Именно благодаря простоте она и была реализована.

Эффективная реализация перестройки сверху до недавнего времени мне была не очевидна. Казалось, нужно откатиться к тому проходу, где был построен экземпляр Ci, и заменить его вызов на экземпляр Cj.

На самом деле всё проще. Достаточно перезаписать экземпляр Ci в транзитную функцию, вызывающую C*:

Func@i {
  〈параметры〉 = <Func@k 〈обобщения〉>;
}

Здесь Func@k — экземпляр для C*. Функция транзитная, последующим проходом автоматической разметки она пометится как drive и успешно прогонится.

При этом историю сигнатуры Func@i следует приписать и сигнатуре Func@k, включать саму Func@i в эту историю не стоит (см. далее).

Хорошо, функцию Ci мы подменили, но что делать с графом, растущим от Ci (т.е. Ci+1Cj-1)? В их истории будет встречаться сигнатура Func@i, по ней их можно будет узнать и удалить. Причём удалить не только из дерева, но и из таблиц сигнатур. Возможный недостаток — если снова где-то в программе появится вызов с подобной сигнатурой, придётся её заново строить.

Про неподвижную точку

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

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

Можно, наверное, даже разрешить повторную специализацию экземпляров. В таком случае неподвижная точка будет неизбежно строиться с точностью до имён функций — экземпляры экземпляров будут иметь другие имена.

Mazdaywik added a commit that referenced this issue Oct 25, 2020
Новый алгоритм перенесён в OptTree-AutoMarkup-GraphExtractor.ref, поскольку
в дальнейшем предполагается объединить всю логику разметки прогоняемых
функций в одном файле.

Функция BasicVertexes сразу вычисляет и множество функций, которые можно
прогнать, множество базисных функций и множество функций, недостижимых
из корня. В результате вычисление функций для прогонки, выполнявшееся
в OptTree-AutoMarkup.ref, стало избыточным.

Автотест на маркировку метатаблиц условно заработал. Теперь в нём нет
бесконечного зацикливания при прогонке, но обнаружилось бесконечное
зацикливание при авторазметке+специализации. Эта проблема известная и ей
посвящена заявка #320. Для подавления бесконечной специализации добавлен
шаблон с единственным динамическим аргументом.
@Mazdaywik
Copy link
Member Author

Mazdaywik commented Oct 26, 2020

Сравнение подходов

Выше предложено два подхода к исправлению ошибки: без повторного запуска специализации и с обобщением сверху. Рассмотрим псевдокод цикла оптимизации для обоих подходов.

Подход с отказом от повторной специализации

Псевдокод будет выглядеть так, ниже мы дадим комментарии по некоторым пунктам:

   // основной этап оптимизации
1. автоматическая разметка $DRIVE и $SPEC
2. повторять до неподвижной точки:
3.     повторять до неподвижной точки:
4.         проход прогонки
5.     проход специализации

   // чистка транзитных экземпляров
6. автоматическая разметка $DRIVE
7. повторять до неподвижной точки:
8.     проход прогонки
9. опциональная чистка дерева от неиспользуемых функций

На втором этапе, чистке транзитных экземпляров, достаточно расставлять только метки $DRIVE, поскольку проходы специализации после него выполняться не будут. Тем более, сейчас (коммит e10d94a) разметка для прогонки и разметка для специализации разнесены.

Чистка дерева от неиспользуемых функций может выполняться как одновременно с оптимизацией, так и сторонним инструментом — см. #228.

Подход с повторной специализацией и перестройкой сверху

Псевдокод будет выглядеть так:

 1. повторять до неподвижной точки (с точностью до имён):
        // основной этап оптимизации
 2.     автоматическая разметка $DRIVE и $SPEC
 3.     повторять до неподвижной точки:
 4.         повторять до неподвижной точки:
 5.             проход прогонки
 6.         проход специализации
        // удаление функций-обобщений и транзитных экземпляров
 7.     автоматическая разметка $DRIVE
 8.     повторять до неподвижной точки
 9.         проход прогонки
10.     чиста дерева от неиспользуемых функций

Тут интересны строчки 7–10. Специализация с перестройкой сверху строит функции-обобщения

Func@i {
  〈параметры〉 = <Func@k 〈обобщения〉>;
}

и их нужно будет встраивать в точки вызова. Для этого у нас есть строчки 7–9. Для достижения неподвижной точки программы необходимо будет удалять как функции-обобщения, так и транзитные экземпляры — ведь их на входе программы не было.

Заметим, что тело цикла здесь совпадает с псевдокодом для предыдущего случая.

Вывод

Реализация второго пути — перестройка сверху + повторение переразметки и специализации до неподвижной точки неявно подразумевает реализацию первого пути.

Т.е. исправив зацикливание путём удаления внешнего цикла, мы реализуем тело цикла для второго варианта.

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

Таким образом, для исправления ошибки нужно разомкнуть наружный цикл. А завернуть программу в новый наружный цикл можно будет тогда, когда потребуется повторная специализация экземпляров (см. #314 (comment)).


P.S. Как в эту схему включить дефорестацию (#165), я пока не придумал.

Mazdaywik added a commit that referenced this issue Oct 27, 2020
Порядок, в котором взаимодействуют различные проходы древесных оптимизаций,
описан при помощи Scheme-подобного интерпретируемого языка. Такое описание
гораздо нагляднее и удобнее в сопровождении, чем набор взаимно-рекурсивных
функций.

Данный интерпретатор может быть проспециализирован, однако, такая возможность
отключена директивами $SPEC. Опыт показывает, что на специализацию
интерпретатора уходит несколько десятков минут (около 40 минут с записью
в лог, без лога не тестировал).
@Mazdaywik
Copy link
Member Author

Мотивация написания DSL в коммите выше: листинги псевдокода из предыдущего комментария гораздо нагляднее, чем набор взаимно-рекурсивных функций. Заметим, что в тексте DSL отсутствуют упоминания дерева. Передача дерева между проходами осуществляется неявно, самим интерпретатором.

Ожидаемо, что этот интерпретатор специализируется. И такой опыт был проведён: была запущена компиляция файла OptTree.ref с ключами оптимизации -OADiS. Сделано четыре важных вывода.

Два вывода по производительности:

Два вывода по архитектуре оптимизации, в контексте текущей задачи:

  • Первая итерация «большого» цикла построила кучу транзитных экземпляров. Вторая итерация эти транзитные экземпляры успешно схлопнула (несхлоптнутыми остались вызовы с активными аргументами, см. выше). Третья и последующие стали страдать фигнёй — разворачивать циклы внутри функции Apply!
  • Было продемонстрировано зацикливание специализатора без интринсиков, на функции Apply. Рассмотрим этот случай поподробнее.

Рассмотрим следующий пример:

$ENTRY Test {
  t.F e.X = <Apply t.F e.X>;
}

Apply {
  s.Fn e.Argument = <Mu s.Fn e.Argument>;

  (t.Closure e.Bounded) e.Argument
    = <Apply t.Closure e.Bounded e.Argument>;
}

$EXTERN Mu;

При запуске на компиляцию (из папки build) со следующими параметрами:

..\bin\rlc-core -C -OADiS test-apply.ref --log=test-apply.log --opt-tree-cycles=40

получим

  $ENTRY Test {
    t.F#1 e.X#1 = <Apply t.F#1 e.X#1>;
  }
  
  Apply {
    s.Fn#1 e.Argument#1 = <Mu s.Fn#1 e.Argument#1>;
  
    (s.Fn#1 e.Bounded#1) e.Argument#1 = <Mu s.Fn#1 e.Bounded#1 e.Argument#1>;
  
    ((s.Fn#1 e.0#0) e.Bounded#1) e.Argument#1
      = <Mu s.Fn#1 e.0#0 e.Bounded#1 e.Argument#1>;
  
    (((s.Fn#1 e.1#0) e.0#0) e.Bounded#1) e.Argument#1
      = <Mu s.Fn#1 e.1#0 e.0#0 e.Bounded#1 e.Argument#1>;
  
    ((((t.#0 e.2#0) e.1#0) e.0#0) e.Bounded#1) e.Argument#1
      = <Apply@4 t.#0 (e.2#0) (e.1#0) (e.0#0) (e.Bounded#1) e.Argument#1>;
  
    (((t.0#0 e.1#0) e.0#0) e.Bounded#1) e.Argument#1
      = <Apply@0 t.0#0 e.1#0 e.0#0 e.Bounded#1 e.Argument#1>;
  
    ((t.#0 e.0#0) e.Bounded#1) e.Argument#1
      = <Apply@0 t.#0 e.0#0 e.Bounded#1 e.Argument#1>;
  
    (t.Closure#1 e.Bounded#1) e.Argument#1
      = <Apply@0 t.Closure#1 e.Bounded#1 e.Argument#1>;
  }
  
  $EXTERN Mu;
  
  Test@0 {
  }
  
  Apply@0 {
  }
  
  Apply@1 {…}
  
  Apply@2 {…}
  
  Apply@3 {…}
  
  Apply@4 {
    s.Fn#1 (e.2#0) (e.1#0) (e.0#0) (e.Bounded0#1) e.Argument0#1
      = <Mu s.Fn#1 e.2#0 e.1#0 e.0#0 e.Bounded0#1 e.Argument0#1>;
  
    (t.Closure#1 e.Bounded#1) (e.2#0) (e.1#0) (e.0#0) (e.Bounded0#1)
    e.Argument0#1
      = <Apply
          t.Closure#1 e.Bounded#1 e.2#0 e.1#0 e.0#0 e.Bounded0#1 e.Argument0#1
        >;
  
    t.dyn#0 (e.2#0) (e.1#0) (e.0#0) (e.Bounded0#1) e.Argument0#1
      = <Apply@0 t.dyn#0 e.2#0 e.1#0 e.0#0 e.Bounded0#1 e.Argument0#1>;
  }

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

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

Mazdaywik added a commit that referenced this issue Oct 28, 2020
• Встраивание процедуры в скрипте.
• Теперь скрипт — набор процедур, выполнение начинается с самой первой.
Mazdaywik added a commit that referenced this issue Oct 28, 2020
Цикл развёрнут в две итерации: полную со специализацией и прогонкой
и усечённую с одной только прогонкой.
Mazdaywik added a commit that referenced this issue Oct 28, 2020
Теперь команда (pass e.Code) выполняет данный участок кода при ненулевом
счётчике и этот счётчик декрементирует. Т.е. объединила в себе старые
команды (on-cycles e.Code) и (pass s.Func e.Arg). Предыдущие команды
pass заменены на call.

Не рекомендуется вкладывать друг в друга операторы pass — возможны
странности.
@Mazdaywik Mazdaywik unpinned this issue Nov 8, 2020
Mazdaywik added a commit that referenced this issue Nov 29, 2020
Удаляются не транзитные экземпляры, а нерекурсивные.

Правильная терминология: транзитные — с одной веткой и без сужений,
полутранзитные: с одной веткой. См. Немытых А.П. «Суперкомпилятор SCP4:
общая структура», 2007.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
1 participant