Mazdaywik / Refal-05

Очень минималистичный компилятор Рефала
https://mazdaywik.github.io/Refal-05/
Other
4 stars 3 forks source link

Переписать сопоставления с образцом #40

Open Mazdaywik opened 2 years ago

Mazdaywik commented 2 years ago

Вынесено из комментария https://github.com/Mazdaywik/Refal-05/issues/33#issuecomment-1256872395.

Актуальная реализация сопоставления с образцом сложная, костыльная и неэффективная. Она унаследована от Простого Рефала, а как правильно компилировать сопоставления с образцом, я тогда не знал.

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

Предлагается сделать сопоставления с образцом в духе диссертации Романенко — каждый распознанный элемент кладётся на стек, диапазоны открытые — это просто края уже распознанных элементов. Система команд Романенко предлагает стек времени выполнения и два «регистра» границ — следствие того, что команды интерпретируются и ради уменьшения объёма байткода (у команд меньше аргументов). Очевидно, что положение сопоставленных элементов на стеке известно во время компиляции, так что вместо стека будет простой массив ячеек и команды будут ссылаться на ячейки явным образом.

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

Сопоставление с образцом в духе диссертации Романенко будет гораздо проще задокументировать — фактически нужно пересказать своими словами соответствующий раздел учебника Турчина.

Тоже самое предполагается в дальнейшем сделать и в Рефале-5λ — https://github.com/bmstu-iu9/refal-5-lambda/issues/204.

Этапы

Mazdaywik commented 2 years ago

Задача не актуальная…

Задача не актуальная, т.к. сопоставление с образцом не является узким местом. Для примера возьмём применение компилятора Рефала-05 к исходникам MSCP-A:

*Compiling mscp-a.ref:
*Compiling accessMSCP.ref:
*Compiling AnalyzeFunDef.ref:
*Compiling basics.ref:
*Compiling c-prefal-decompiled.ref:
*Compiling DiofEqs.ref:
*Compiling Drive.ref:
*Compiling Generalize.ref:
*Compiling prefal-decompiled.ref:
*Compiling residual.ref:
*Compiling Stack.ref:
*Compiling Unfold_SCP.ref:
*Compiling WordEqsCases.ref:
*Compiling WordEquations.ref:
+Linking D:\Mazdaywik\Documents\Refal-05\lib/Library.c
+Linking D:\Mazdaywik\Documents\Refal-05\lib/refal05rts.c
*** Compilation successed ***

Total program time: 8.641 seconds (100.0 %).
(Total refal time): 7.044 seconds (81.5 %).
Linear result time: 2.695 seconds (31.2 %).
Linear pattern time: 1.659 seconds (19.2 %).
Builtin time: 1.597 seconds (18.5 %).
t- and e-var copy time: 1.349 seconds (15.6 %).
Open e-loop time (clear): 0.891 seconds (10.3 %).
Repeated e-var match time (inside e-loops): 0.450 seconds (5.2 %).
Step count 15699483
Memory used 1987669 nodes, 1987669 * 16 = 31802704 bytes

Построение результатных выражений (31,2 %) требует в полтора больше времени, чем сопоставление с образцом (19,2 %).

Если мы просуммируем все компоненты стадии анализа (19,2 + 10,3 + 5,2 = 34,7 %) и стадии синтеза (31,2 + 15,6 = 46,7 %), то всё равно выйдет, что стадия синтеза выполняется в 1,35 раз дольше, чем стадия анализа. Оптимизация сопоставления с образцом может ускорить только два из трёх компонентов стадии анализа, т.к. сопоставление с повторными переменными останется неизменным.

Тестирование выполнялось на коммитах https://github.com/Mazdaywik/Refal-05/commit/c5891a3b820a7379c2ceb963777745947ca481d1 Рефала-05 и https://github.com/Mazdaywik/refal-5-framework/commit/1c1888c972786f1047419f3cadc81fda405d2d43 фреймворка. Во фреймворке несколько последних коммитов посвящены борьбе с избыточными копированиями переменных.

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

Интересно разобраться, почему стадия синтеза такая медленная. Нужно выполнить профилирование. Анализ производительности выполнялся на Linux, вернее на WSL версии 2, т.к. там есть соответствующие инструменты (gprof на Windows даёт неточный результат).

Рефал-05 был собран gcc с флагами -DR05_CLOCK_SKIP=20 -O1 -pg (включает код профилирования для gprof), был склонирован репозиторий MSCP-A, адаптирован для Рефала-05 (удалены лишние функции) и собраны в нём все исходники:

mazdaywik@Mazdaywik-NB10:~/MSCP-A05$ ../Refal-05/bin/refal05c *.ref
*Compiling AnalyzeFunDef.ref:
*Compiling DiofEqs.ref:
*Compiling Drive.ref:
*Compiling Generalize.ref:
*Compiling Stack.ref:
*Compiling Unfold_SCP.ref:
*Compiling WordEqsCases.ref:
*Compiling WordEquations.ref:
*Compiling accessMSCP.ref:
*Compiling basics.ref:
*Compiling residual.ref:
*** Compilation successed ***

Total program time: 26.098 seconds (100.0 %).
(Total refal time): 19.633 seconds (75.2 %).
Linear result time: 7.500 seconds (28.7 %).
Linear pattern time: 7.017 seconds (26.9 %).
Builtin time: 6.465 seconds (24.8 %).
Open e-loop time (clear): 2.559 seconds (9.8 %).
Repeated e-var match time (inside e-loops): 1.677 seconds (6.4 %).
t- and e-var copy time: 0.740 seconds (2.8 %).
Repeated t-var match time (inside e-loops): 0.140 seconds (0.5 %).
Step count 13381086
Memory used 1987669 nodes, 1987669 * 32 = 63605408 bytes

(Это запуск Рефала-05, собранного с -pg, специальная сборка работает медленнее обычной.)

Полученный профиль выглядит так:

Flat profile:

Each sample counts as 0.01 seconds.
  %   cumulative   self              self     total           
 time   seconds   seconds    calls  us/call  us/call  name    
 18.56      0.80     0.80 122974329     0.01     0.01  r05_alloc_node
 14.15      1.41     0.61   839352     0.73     1.21  copy_nonempty_evar
 10.44      1.86     0.45   334882     1.34     1.35  output_func
  6.50      2.14     0.28 139759020     0.00     0.00  ensure_memory
  5.68      2.39     0.25 42443937     0.01     0.01  list_splice
  3.25      2.53     0.14 21891348     0.01     0.01  r05_brackets_left
  3.13      2.66     0.14 147716732     0.00     0.00  r05_empty_seq
  …

Существенное время в работе программы занимают r05_alloc_node и copy_nonempty_evar (замер последней — 0,61 секунда (третья колонка) в целом согласуется с t- and e-var copy time: 0.740 seconds (2.8 %).). Заметную долю времени требует также output_func (вызывается из Putout, компилятор действительно генерирует длинные тексты на Си), ensure_memory и list_splice. Т.е., в топ-5 четыре функции относятся к операциям синтеза.

С чего бы это? r05_alloc_node действительно вызывается очень много раз (122 974 329). Но ensure_memory и r05_empty_seq вызываются даже чаще (соответственно, 139 759 020 и 147 716 732), но требуют меньше времени. Давайте посмотрим на r05_alloc_node и подумаем, что же в ней может выполняться так медленно?

https://github.com/Mazdaywik/Refal-05/blob/c5891a3b820a7379c2ceb963777745947ca481d1/lib/refal05rts.c#L764-L772

Всего лишь взятие звена из списка и его частичная инициализация (вызов ensure_memory не считаем, т.к. он в профиле подсчитан отдельно). Неужели эти три присваивания такие медленные?

Ответ нам даст другой диагностический инструмент — Cachegrind, инструмент Valgrind’а. Отладчик Valgrind интерпретирует машинный код инструкция за инструкцией, выполняя при этом дополнительную диагностику. Чаще всего используется инструмент Memcheck, проверяющий правильность обращения к памяти (позволяет выявлять работу с неправильными указателями, выход за границы объектов в динамической памяти, утечки памяти и т.д.). Инструмент Cachegrind моделирует кэш процессора и для каждого обращения к памяти определяет, был ли промах кэша. Конечно, модель, скорее всего, отличается от реального кэша, но результат, скорее всего, адекватен.

Cachegrind отображает статистику в 10 колонок: первые три колонки на кэш инструкций, вторые три — на операции чтения, третьи три — на операции записи, последняя — имя функции или строчка кода. В каждой группе из трёх колонок первая — число инструкций/записей/чтений, вторая — промах кэша L1, третья — промах кэша L2.

Полный профиль: profile-cachegrind.txt.

Начало профиля выглядит так:

--------------------------------------------------------------------------------
Ir            I1mr    ILmr  Dr          D1mr       DLmr      Dw          D1mw       DLmw       file:function
--------------------------------------------------------------------------------
2,068,034,248       2     2 147,716,732          0         0           0          0         0  /home/mazdaywik/Refal-05/src/../lib/refal05rts.c:r05_empty_seq
1,548,224,347       3     3 376,872,051  2,026,113   744,485 376,872,051      4,776        12  /home/mazdaywik/Refal-05/src/../lib/refal05rts.c:list_splice
1,229,743,290       2     2 491,897,316 37,845,926 7,009,149 491,897,316 10,056,602 1,931,757  /home/mazdaywik/Refal-05/src/../lib/refal05rts.c:r05_alloc_node
  931,135,184       4     4 297,310,110  7,054,425   191,442 197,676,447     11,176        21  /home/mazdaywik/Refal-05/src/../lib/refal05rts.c:r05_brackets_left
  855,569,869       1     1 122,224,267          0         0 244,448,534  1,165,303   392,675  /home/mazdaywik/Refal-05/src/../lib/refal05rts.c:weld
  633,811,612       4     4 145,134,156    782,198   123,861 127,152,110     25,325        34  /home/mazdaywik/Refal-05/src/../lib/refal05rts.c:r05_tvar_left
  628,874,731       2     2 256,204,531  3,634,465   332,348  56,176,385          0         0  /home/mazdaywik/Refal-05/src/../lib/refal05rts.c:move_left
  603,184,565       2     2 119,049,585     27,000        60  43,651,515      6,542         2  /home/mazdaywik/Refal-05/src/../lib/refal05rts.c:fast_clock
  600,911,136     175    94 190,532,500 41,371,420 4,565,250 103,106,540      1,729        60  /home/mazdaywik/Refal-05/src/../lib/refal05rts.c:copy_nonempty_evar
  573,060,629   1,910     9 279,549,716      1,751         9   6,002,602    987,881   987,869  /home/mazdaywik/Refal-05/src/../lib/refal05rts.c:ensure_memory
  378,648,842   1,039    80  86,605,877    280,598    38,447  75,675,069      3,469        11  /home/mazdaywik/Refal-05/src/../lib/refal05rts.c:r05_svar_left

Видно, что функции r05_alloc_node и copy_nonempty_evar имеют значительную долю промахов кэша. Рассмотрим профиль первой функции:

368,922,987      1    1           0          0         0 122,974,329          0         0  struct r05_node *r05_alloc_node(enum r05_datatag tag) {
          .      .    .           .          .         .           .          .         .    struct r05_node *node;
          .      .    .           .          .         .           .          .         .  
122,974,329      1    1           0          0         0 122,974,329      2,762        10    ensure_memory();
122,974,329      0    0 122,974,329          0         0           0          0         0    node = s_free_ptr;
245,948,658      0    0 122,974,329 37,845,926 7,009,149 122,974,329          0         0    s_free_ptr = s_free_ptr->next;
122,974,329      0    0           0          0         0 122,974,329 10,053,840 1,931,747    node->tag = tag;
          .      .    .           .          .         .           .          .         .    return node;
245,948,658      0    0 245,948,658          0         0           0          0         0  }

Число 245 млн в первой колонке у строки s_free_ptr = s_free_ptr->next; обусловлено тем, что в ней выполняются две инструкции — инструкция чтения поля next и инструкция записи в глобальную переменную. Из инструкций чтения в этой строке примерно треть промахиваются мимо кэша L1 и каждая двадцатая — мимо кэша L2.

При выполнении строчки node->tag = tag; в двенадцатой части случаев строка кэша, содержащая узел списка, помечена доступной для чтения. Для нас это роли не играет.

          .      .    .           .          .         .           .          .         .  static void copy_nonempty_evar(
          .      .    .           .          .         .           .          .         .    struct r05_node *evar_b_sample, struct r05_node *evar_e_sample
  5,036,112     22   13           0          0         0   3,357,408      1,071         8  ) {
    839,352      0    0     839,352     34,427     3,397           0          0         0    struct r05_node *limit = evar_e_sample->next;
  1,678,704      0    0           0          0         0     839,352        658        52    clock_t start_copy_time = clock();
          .      .    .           .          .         .           .          .         .  
  1,678,704      0    0           0          0         0           0          0         0    struct r05_node *bracket_stack = 0;
          .      .    .           .          .         .           .          .         .  
 95,921,192      0    0           0          0         0           0          0         0    while (evar_b_sample != limit) {
 94,242,488     76   41  47,121,244 33,338,144 3,754,456  47,121,244          0         0      struct r05_node *copy = r05_alloc_node(evar_b_sample->tag);
          .      .    .           .          .         .           .          .         .  
141,363,732      0    0  47,121,244          0         0           0          0         0      if (is_open_bracket(copy)) {
  3,827,940      0    0           0          0         0   3,827,940          0         0        copy->info.link = bracket_stack;
  7,655,880      0    0           0          0         0           0          0         0        bracket_stack = copy;
 86,586,608     77   40           0          0         0           0          0         0      } else if (is_close_bracket(copy)) {
          .      .    .           .          .         .           .          .         .        struct r05_node *open_cobracket = bracket_stack;
          .      .    .           .          .         .           .          .         .  
  7,655,880      0    0           0          0         0           0          0         0        assert(bracket_stack != 0);
  7,655,880      0    0   3,827,940     42,538         0           0          0         0        bracket_stack = bracket_stack->info.link;
          .      .    .           .          .         .           .          .         .        r05_link_brackets(open_cobracket, copy);
          .      .    .           .          .         .           .          .         .      } else {
 78,930,728      0    0  39,465,364          0         0  39,465,364          0         0        copy->info = evar_b_sample->info;
          .      .    .           .          .         .           .          .         .      }
          .      .    .           .          .         .           .          .         .  
 47,121,244      0    0  47,121,244  7,903,153   807,268           0          0         0      evar_b_sample = evar_b_sample->next;
          .      .    .           .          .         .           .          .         .    }
          .      .    .           .          .         .           .          .         .  
  1,678,704      0    0           0          0         0           0          0         0    assert(bracket_stack == 0);
          .      .    .           .          .         .           .          .         .  
  1,678,704      0    0           0          0         0     839,352          0         0    add_copy_tevar_time(clock() - start_copy_time);
  5,036,112      0    0   4,196,760     25,261        78           0          0         0  }

Здесь для нас существенны промахи кэша в строчках struct r05_node *copy = r05_alloc_node(evar_b_sample->tag); и evar_b_sample = evar_b_sample->next; — при создании копии оригинал выражения часто оказывается вне кэша и его приходится подгружать. Почему после чтения evar_b_sample->tag весь узел не закэшировался и последующее чтение evar_b_sample->next также даёт промахи кэша, мне не очевидно.

Заметное время работы имеет и функция list_splice, посмотрим и на неё тоже:

          .      .    .           .          .         .           .          .         .  static struct r05_node *list_splice(
          .      .    .           .          .         .           .          .         .    struct r05_node *res, struct r05_node *begin, struct r05_node *end
339,551,496      0    0           0          0         0 212,219,685          0         0  ) {
339,551,496      1    1           0          0         0  42,443,937      4,776        12    if ((res != begin) && ! r05_empty_seq(begin, end)) {
          .      .    .           .          .         .           .          .         .      struct r05_node *prev_res = res->prev;
 40,736,143      0    0  40,736,143  1,213,659   462,350           0          0         0      struct r05_node *prev_begin = begin->prev;
 40,736,143      0    0  40,736,143    742,905   282,074           0          0         0      struct r05_node *next_end = end->next;
          .      .    .           .          .         .           .          .         .  
122,208,429      0    0  40,736,143     69,549        61  40,736,143          0         0      weld(prev_res, begin);
122,208,429      0    0           0          0         0  40,736,143          0         0      weld(end, res);
122,208,429      0    0           0          0         0  40,736,143          0         0      weld(prev_begin, next_end);
          .      .    .           .          .         .           .          .         .    } else {
          .      .    .           .          .         .           .          .         .      /* Цель достигнута сама по себе */
 84,887,874      1    1           0          0         0           0          0         0      return res;
          .      .    .           .          .         .           .          .         .    }
          .      .    .           .          .         .           .          .         .  
 81,472,286      1    1           0          0         0           0          0         0    return begin;
254,663,622      0    0 254,663,622          0         0           0          0         0  }

Странно, что строчка struct r05_node *prev_res = res->prev; профиля не имеет, а строчка weld(prev_res, begin); имеет некоторое количество промахов. Возможно, это последствия оптимизации в режиме -O1, так что строчку с weld следует трактовать как:

122,208,429      0    0  40,736,143     69,549        61  40,736,143          0         0      weld(res->prev, begin);

Здесь тоже интересно, что обращения к полям ->next и ->prev приводят к заметному количеству промахов кэша.

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

…но соблазнительная

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

STrusov commented 2 years ago

Почему после чтения evar_b_sample->tag весь узел не закэшировался и последующее чтение evar_b_sample->next также даёт промахи кэша, мне не очевидно.

Действительно, размер https://github.com/Mazdaywik/Refal-05/blob/c5891a3b820a7379c2ceb963777745947ca481d1/lib/refal05rts.h#L60-L70 16 или 32 байта, что целиком укладывается в линейку кеша. Однако, результат чтения сохраняется в evar_b_sample и следом спроисходит чтение данных по указателю: https://github.com/Mazdaywik/Refal-05/blob/c5891a3b820a7379c2ceb963777745947ca481d1/lib/refal05rts.c#L812 Вероятно, тут проблема у инструмента из-за сопоставления оптимизированного машинного кода исходному тексту.

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

Для 64-х разрядных платформ возможно уменьшить потребление памяти, если заменить указатели индексами и объединить с тегом, что реализовал в интерпретаторе:

typedef struct rf_cell {
   union {
      uint64_t    data;    ///< Используется для сравнения.
      rf_int      num;     ///< Число.
      wchar_t     chr;     ///< Символ (буква).
      rf_index    link;    ///< Узел в графе.
   };
   rf_type     tag :4;     ///< Тип содержимого.
   rf_index    prev:28;    ///< Индекс предыдущей ячейки.
   rf_index    tag2:4;     ///< Вспомогательный тип.
   rf_index    next:28;    ///< Индекс последующей ячейки.
} rf_cell;

https://github.com/STrusov/refal-machine/blob/v0.1.4/src/refal.h#L93-L120 На 32-х битах экономия выйдет меньше, но так же возможно не трать на хранение тега отдельное двойное слово.

Такое решение имеет свои недостатки, поскольку приходится размещать всё в непрерывном массиве (однако, в Linux при реаллокациях копирование памяти не происходит, система переотображает страницы памяти по другим адресам в случае необходимости; в Windows с этим сложнее, придётся резервировать адреса).

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

Тем не менее, сравнительное тестирование показало некоторый выигрыш при интерпретации lambda.ref по сравнению с исполнением скомпилированного Рефалом-05 кода (2мин 47 сек против 3 мин 32 сек без учёта погрешности; для решета Эратосфена разница существеннее, там же по ссылке).

Mazdaywik commented 2 years ago

Сравнительное тестирование осуществлялось под Linux? Актуальная версия Рефала-05 по нескольку раз на каждый шаг вызывает функцию clock(), которая на Linux выполняется очень медленно (на Windows приемлемое время) — https://github.com/bmstu-iu9/refal-5-lambda/issues/206. Чтобы увидеть результаты этих замеров, нужно в командной строке включить макрос -DR05_SHOW_STAT. Чтобы снизить накладные расходы, нужно использовать макрос -DR05_CLOCK_SKIP=20.

Без макроса R05_SHOW_SKIP потеря производительности может быть в разы.

STrusov commented 2 years ago

Действительно, проглядел, что используется clock() (прогонял на версии 3.1 давно, тогда увидел #ifdef R05_SHOW_STAT и на этом успокоился, не озадачившись именем макроса, из которого должно было быть очевидно). R05CCOMP задана равной gcc -O2 -Wall -g -DR05_POSIX

Сейчас попробовал кардинально, вписал в версию 3.1

inline clock_t fast_clock() { return 0; }
#define clock fast_clock

Посмотрел среди импортирумых функций, clock отсутствует. Загрузил в отладчик gdb, вывел листинг дизассемблера для https://github.com/Mazdaywik/Refal-05/blob/c5891a3b820a7379c2ceb963777745947ca481d1/lib/refal05rts.c#L498-L506 не нашёл в нём вызовы fast_clock. Сама fast_clock() так же отсутствует, оптимизировалась.

На том же процессоре результат измерений не изменился существенно (транслятор gcc 10.3.0 сменился на 11.3.0; попробовал clang 14.0.6 - всё так же).

Причина, по-видимому, в том, что lambda.ref практически всё время тратит на работу спамятью при аргументах начиная с 5. Вот запуск собранного с R05_SHOW_STAT:

$ echo 5 | ./a.out
Enter a number:
120

Total program time: 207.024 seconds (100.0 %).
(Total refal time): 206.672 seconds (99.8 %).
t- and e-var copy time: 205.615 seconds (99.3 %).
Linear result time: 0.599 seconds (0.3 %).
Linear pattern time: 0.458 seconds (0.2 %).
Builtin time: 0.352 seconds (0.2 %).
Step count 1903604
Memory used 2103882 nodes, 2103882 * 32 = 67324224 bytes

perf stat показывает, что основной простой происхдит в бэк-энде процесора, что характерно для промахов кеша:

$ echo 5 | perf stat ./a.out
Enter a number:
120

Total program time: 203.996 seconds (100.0 %).
(Total refal time): 203.628 seconds (99.8 %).
t- and e-var copy time: 202.547 seconds (99.3 %).
Linear result time: 0.609 seconds (0.3 %).
Linear pattern time: 0.473 seconds (0.2 %).
Builtin time: 0.368 seconds (0.2 %).
Step count 1903604
Memory used 2103882 nodes, 2103882 * 32 = 67324224 bytes

 Performance counter stats for './a.out':

        204 010,77 msec task-clock:u              #    1,000 CPUs utilized
                 0      context-switches:u        #    0,000 /sec
                 0      cpu-migrations:u          #    0,000 /sec
            16 528      page-faults:u             #   81,015 /sec
   673 476 090 801      cycles:u                  #    3,301 GHz
    55 525 940 478      stalled-cycles-frontend:u #    8,24% frontend cycles idle
   553 894 542 462      stalled-cycles-backend:u  #   82,24% backend cycles idle
   116 917 305 692      instructions:u            #    0,17  insn per cycle
                                                  #    4,74  stalled cycles per insn
    37 456 011 800      branches:u                #  183,598 M/sec
       466 657 610      branch-misses:u           #    1,25% of all branches

     204,020794109 seconds time elapsed

     202,989594000 seconds user
       1,019947000 seconds sys

Вот perf report после $ echo 5 | perf record ./a.out

# Overhead  Command  Shared Object         Symbol
# ........  .......  ....................  ..................................
#
    97.94%  a.out    a.out                 [.] copy_nonempty_evar
     1.20%  a.out    a.out                 [.] ensure_memory
     0.57%  a.out    a.out                 [.] r05_empty_seq
     0.02%  a.out    [vdso]                [.] __vdso_clock_gettime
     0.01%  a.out    [unknown]             [k] 0xffffffff9d12ac23
     0.01%  a.out    [unknown]             [k] 0xffffffff9dc00d30
     0.01%  a.out    [unknown]             [k] 0xffffffff9d54f8a8
     0.01%  a.out    a.out                 [.] r05_splice_tvar
     0.01%  a.out    [unknown]             [k] 0xffffffff9d54f873
     0.01%  a.out    [unknown]             [k] 0xffffffff9dc00000
     0.01%  a.out    libc.so.6             [.] clock
     0.01%  a.out    [unknown]             [k] 0xffffffff9dc0000c
     0.01%  a.out    a.out                 [.] r05c_Eval
     0.01%  a.out    a.out                 [.] r05_tvar_left
     0.01%  a.out    a.out                 [.] move_left
     0.01%  a.out    [unknown]             [k] 0xffffffff9da53262
     0.01%  a.out    a.out                 [.] main
     0.01%  a.out    a.out                 [.] r05_alloc_node
     0.01%  a.out    [unknown]             [k] 0xffffffff9da4a07b
     0.01%  a.out    [unknown]             [k] 0xffffffff9d54f89c
     0.01%  a.out    a.out                 [.] r05_function_left
     0.01%  a.out    a.out                 [.] r05c_Lookup
     0.01%  a.out    libc.so.6             [.] clock_gettime
     0.01%  a.out    a.out                 [.] r05_brackets_left
     0.01%  a.out    a.out                 [.] r05_svar_left
     0.00%  a.out    a.out                 [.] after_step
Mazdaywik commented 2 years ago
$ echo 5 | ./a.out
Enter a number:
120

Total program time: 207.024 seconds (100.0 %).
(Total refal time): 206.672 seconds (99.8 %).
t- and e-var copy time: 205.615 seconds (99.3 %).
Linear result time: 0.599 seconds (0.3 %).
Linear pattern time: 0.458 seconds (0.2 %).
Builtin time: 0.352 seconds (0.2 %).
Step count 1903604
Memory used 2103882 nodes, 2103882 * 32 = 67324224 bytes
# Overhead  Command  Shared Object         Symbol
# ........  .......  ....................  ..................................
#
    97.94%  a.out    a.out                 [.] copy_nonempty_evar
     1.20%  a.out    a.out                 [.] ensure_memory
     …

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

Но я ничего менять не буду, т.к. в приоритете понятность и переносимость, а не производительность. Файл lambda.ref — это искусственный тестовый пример, а для меня актуальнее производительность реальных программ, где копирования данных не требуют 99,8 % времени.

А про утилиту perf не знал. Спасибо.

STrusov commented 2 years ago

Файл lambda.ref — это искусственный тестовый пример, а для меня актуальнее производительность реальных программ, где копирования данных не требуют 99,8 % времени.

Да, это понятно. Про переход от указателей к индексам написал, поскольку позволяет достаточно просто передавать по сети. Сам я пока не придумал, как это можно использовать, но вдруг кто-то из Ваших студентов прочтёт. Тут просто подвернулся повод с промахами кеша.

Для проверки гипотезы, что размер ячейки памяти в двусвязном списке влияет на количество промахов, сравнил время исполнения lambda.ref интерпретатором, собранным в двух вариантах.

16 байт

typedef uint32_t rf_index;

typedef struct rf_cell {
   union {
      uint64_t    data;    ///< Используется для сравнения.
      rf_int      num;     ///< Число.
      wchar_t     chr;     ///< Символ (буква).
      rf_index    link;    ///< Узел в графе.
   };
   rf_type     tag :4;     ///< Тип содержимого.
   rf_index    prev:28;    ///< Индекс предыдущей ячейки.
   rf_index    tag2:4;     ///< Вспомогательный тип.
   rf_index    next:28;    ///< Индекс последующей ячейки.
} rf_cell;

static_assert(sizeof(rf_cell) == 16);
$ echo 5 | perf stat refal lambda.ref
Enter a number:
120

 Performance counter stats for 'refal lambda.ref':

        159 972,64 msec task-clock:u              #    1,000 CPUs utilized
                 0      context-switches:u        #    0,000 /sec
                 0      cpu-migrations:u          #    0,000 /sec
             8 283      page-faults:u             #   51,778 /sec
   544 025 246 342      cycles:u                  #    3,401 GHz
       352 707 077      stalled-cycles-frontend:u #    0,06% frontend cycles idle
   504 324 885 458      stalled-cycles-backend:u  #   92,70% backend cycles idle
   188 764 520 602      instructions:u            #    0,35  insn per cycle
                                                  #    2,67  stalled cycles per insn
    19 573 002 210      branches:u                #  122,352 M/sec
       320 187 437      branch-misses:u           #    1,64% of all branches

     159,981995789 seconds time elapsed

     159,944681000 seconds user
       0,026665000 seconds sys

32 байта:

typedef uint64_t rf_index;

typedef struct rf_cell {
   union {
      uint64_t    data;    ///< Используется для сравнения.
      rf_int      num;     ///< Число.
      wchar_t     chr;     ///< Символ (буква).
      rf_index    link;    ///< Узел в графе.
   };
   rf_type     tag :4;     ///< Тип содержимого.
   rf_index    prev:60;    ///< Индекс предыдущей ячейки.
   rf_index    tag2:4;     ///< Вспомогательный тип.
   rf_index    next:60;    ///< Индекс последующей ячейки.
   uint64_t    pad;
} rf_cell;

static_assert(sizeof(rf_cell) == 32);
$ echo 5 | perf stat ../refal lambda.ref
Enter a number:
120

 Performance counter stats for '../refal lambda.ref':

        210 574,62 msec task-clock:u              #    1,000 CPUs utilized
                 0      context-switches:u        #    0,000 /sec
                 0      cpu-migrations:u          #    0,000 /sec
            16 502      page-faults:u             #   78,367 /sec
   735 662 116 786      cycles:u                  #    3,494 GHz
       619 530 670      stalled-cycles-frontend:u #    0,08% frontend cycles idle
   627 577 165 458      stalled-cycles-backend:u  #   85,31% backend cycles idle
   181 168 704 484      instructions:u            #    0,25  insn per cycle
                                                  #    3,46  stalled cycles per insn
    19 567 951 302      branches:u                #   92,926 M/sec
       412 993 025      branch-misses:u           #    2,11% of all branches

     210,585433097 seconds time elapsed

     210,550174000 seconds user
       0,023332000 seconds sys

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

Mazdaywik commented 2 years ago

Списковое представление поля зрения — атака на кэш процессора. Но оно простое в реализации и понимании.

Массивное представление память расходует экономнее, но требует другого стиля программирования. В частности, форматы многоместных функций нужно будет писать не <F (e.X) e.Y> или <F e.X (e.Y)>, а <F (e.X) (e.Y)>, либо (как это сделано в Рефале Плюс) форматы функций явно описывать:

$func F (e.X) e.Y = e.Z (e.A);

и проверять их соблюдение на стадии компиляции (можно говорить, что компилятор в этом случае формат <F (e.X) e.Y> будет неявно заменять на <F (e.X) (e.Y)>).

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

STrusov commented 2 years ago

Списковое представление у нас на верхнем уровне абстракции. На уровне реализации, Рефал-05 не аллоцирует каждый элемент списка отдельно, а размешает их в массиве https://github.com/Mazdaywik/Refal-05/blob/c5891a3b820a7379c2ceb963777745947ca481d1/lib/refal05rts.c#L666-L671 Таких массивов может быть много, потому от указателей не уйти. Я сделал один массив на всё (массив переаллоцируюется при необходимости). В таком случае указатели просто заменить на индексы.

Mazdaywik commented 2 years ago

struct memory_chunk это всего лишь оптимизация, она ни на что существенно не влияет. Можно было каждую struct r05_node выделять отдельным вызовом malloc(). Это было бы немного медленнее + требовалось бы больше памяти (неявные накладные расходы внутри системной кучи на каждый кусочек).

Речь о другом. На данный момент известны три способа представлять данные Рефала в памяти:

Можно придумать и другие способы представлять данные Рефала, но я перечислил те, которые есть в уже работающих диалектах.

Функция вида

F {
  t.X = t.X t.X;
}

в представлении с плоским списком будет выполняться за линейное время от длины записи значения t.X. В представлении с подвешенными скобками и в массивном представлении эта функция будет выполняться за константу.

В функции

F {
  e.X DeleteMe e.Y = e.X e.Y;
}

правая часть будет строиться за константу в обеих списковых реализациях. В массивовой она будет строиться за O(|e.X| + |e.Y|), где |•| — длина в термах, т.к. нужно выделить новый массив соответствующей длины и скопировать в него значения выражений e.X и e.Y.

F {
  (e.X) = e.X;
}

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

STrusov commented 2 years ago

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

struct memory_chunk это всего лишь оптимизация, она ни на что существенно не влияет. Можно было каждую struct r05_node выделять отдельным вызовом malloc(). Это было бы немного медленнее + требовалось бы больше памяти (неявные накладные расходы внутри системной кучи на каждый кусочек).

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

В функции

F {
  e.X DeleteMe e.Y = e.X e.Y;
}

правая часть будет строиться за константу в обеих списковых реализациях. В массивовой она будет строиться за O(|e.X| + |e.Y|), где |•| — длина в термах, т.к. нужно выделить новый массив соответствующей длины и скопировать в него значения выражений e.X и e.Y.

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

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

Допустим, требуется удалить элементы 2 и 3:

   [ 1 ]<--->[ 2 ]<--->[ 3 ]<--->[ 4 ]

а) [ 1 ]<----------------------->[ 4 ]

б) [ 1 ]<--->[ 4 ] --- [ х ]<--->[ х ]

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

Mazdaywik commented 1 year ago

Подумалось тут, что раз узкое место в выделении памяти из-за нелокальности данных, то локальность можно повышать принудительными сборками мусора по алгоритму Чени.

Сборщик мусора Чени является копирующим сборщиком мусора. Куча делится на две полукучи, в каждый момент времени используется только одна из половин. В момент её заполнения происходит сборка мусора — все живые объекты копируются во вторую половину с одновременным уплотнением. Сборщик мусора Чени осуществляет обход графа объектов в ширину с одновременным копированием (в реализации это очень изящно), За счёт этого объекты, близкие к корню, копируются первыми и оказываются поблизости. Также, если из объекта есть несколько ссылок на другие объекты, то эти дочерние объекты тоже окажутся поблизости. Т.е. эта сборка мусора будет повышать локальность данных.

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

Алгоритм Чени обходит граф объектов, в частности, для обычных узлов он будет смотреть на ссылки вперёд и назад, для скобочных (круглых и угловых) ещё и на поле ссылки. Таким образом, при копировании будут учитываться и связи между скобками. Корнем будет ссылка на пару скобок ведущего терма вызова функции.

Также не решён вопрос: когда проводить сборку мусора? На него могут быть два ответа: при исчерпании памяти и время от времени. В первом случае мы вообще отказываемся от пула свободных узлов, все новые узлы будут выделяться подряд из нераспределённой части кучи — все выделения новых узлов будут обращаться к последовательным ячейкам памяти. Во втором случае всё как обычно, только раз в N шагов выполняется профилактическая сборка мусора.

Т.е. всего 2 × 2 = 4 варианта. Их надо проверить на практике и определить, будет ли в них толк. С одной стороны повышаем локальность данных. С другой стороны, сама отдельная процедура сборки мусора это накладной расход.