Open Mazdaywik opened 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
и подумаем, что же в ней может выполняться так медленно?
Всего лишь взятие звена из списка и его частичная инициализация (вызов 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 может быть отказ от генерации всего текста программы в памяти, вместо этого нужно выписывать строчки в файл сразу, по мере их порождения. Это уменьшит потребление памяти компилятором и ускорит операции синтеза выражений.
Переписывание сопоставления с образцом оценочно должно заметно сократить объём рантайма без существенного изменения объёма кодогенератора. Также, не исключено, что кодогенерация в чём-то упростится.
Почему после чтения
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 сек без учёта погрешности; для решета Эратосфена разница существеннее, там же по ссылке).
Сравнительное тестирование осуществлялось под 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
потеря производительности может быть в разы.
Действительно, проглядел, что используется 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
$ 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
не знал. Спасибо.
Файл
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, частота плавает - попадает на разные ядра - потому о точности измерений говорить не приходится).
Списковое представление поля зрения — атака на кэш процессора. Но оно простое в реализации и понимании.
Массивное представление память расходует экономнее, но требует другого стиля программирования. В частности, форматы многоместных функций нужно будет писать не <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) представляет каждый терм отдельным узлом, узлы-скобки содержат указатели на кольцевые списки с содержимым скобок. Копирование выражения сводится к копированию только узлов верхнего уровня, т.к. несколько скобочных термов могут ссылаться на одно и то же содержание. Но это усложнит генерацию кода и рантайм. Для программ, где и так оптимизированы копирования значений, данное представление, скорее всего, принесёт только накладные расходы.
Списковое представление у нас на верхнем уровне абстракции. На уровне реализации, Рефал-05 не аллоцирует каждый элемент списка отдельно, а размешает их в массиве https://github.com/Mazdaywik/Refal-05/blob/c5891a3b820a7379c2ceb963777745947ca481d1/lib/refal05rts.c#L666-L671 Таких массивов может быть много, потому от указателей не уйти. Я сделал один массив на всё (массив переаллоцируюется при необходимости). В таком случае указатели просто заменить на индексы.
struct memory_chunk
это всего лишь оптимизация, она ни на что существенно не влияет. Можно было каждую struct r05_node
выделять отдельным вызовом malloc()
. Это было бы немного медленнее + требовалось бы больше памяти (неявные накладные расходы внутри системной кучи на каждый кусочек).
Речь о другом. На данный момент известны три способа представлять данные Рефала в памяти:
e.X A
сопоставлялся у него за линейное время.Можно придумать и другие способы представлять данные Рефала, но я перечислил те, которые есть в уже работающих диалектах.
Функция вида
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;
}
будет выполняться за константу на плоских списках и массивах, но может выполняться за линию на списках с подвешенными скобками. За линию или за константу — зависит от того, сколько ссылок на этот скобочный терм имеется в поле зрения.
Про «хранть всё в одном массиве» тоже совсем по другому поводу писал. Если есть возможность передать данные по сети, гипотетически, получаются распределённые вычисления. Если есть возможность сохранить в файл, значит можно при закрытии приложения сохранить состояние и позже продолжить вычисления. Как из такой возможности извлечь пользу на практике, пока не ясно.
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 ] --- [ х ]<--->[ х ]
Если проанализировать значение указателей, возможно в подходящих случаях выбрать вариант б) и избежать фрагментации. Значит списковое представление не только простое и понятное, но и ему есть куда расти.
Подумалось тут, что раз узкое место в выделении памяти из-за нелокальности данных, то локальность можно повышать принудительными сборками мусора по алгоритму Чени.
Сборщик мусора Чени является копирующим сборщиком мусора. Куча делится на две полукучи, в каждый момент времени используется только одна из половин. В момент её заполнения происходит сборка мусора — все живые объекты копируются во вторую половину с одновременным уплотнением. Сборщик мусора Чени осуществляет обход графа объектов в ширину с одновременным копированием (в реализации это очень изящно), За счёт этого объекты, близкие к корню, копируются первыми и оказываются поблизости. Также, если из объекта есть несколько ссылок на другие объекты, то эти дочерние объекты тоже окажутся поблизости. Т.е. эта сборка мусора будет повышать локальность данных.
Для классической списковой реализации, на первый взгляд, сложная процедура сборки мусора не нужна — достаточно просто обойти и скопировать поле зрения слева-направо. Это повысит локальность данных, соседние узлы в поле зрения окажутся и по соседству в памяти, но это может быть не совсем адекватно. Данные в Рефале иерархичны, круглые скобки обеспечивают иерархию. Часто внутрь скобочного терма спускаться не требуется — находится парная скобка и участок между скобками целиком переносится на новое место без заглядывания внутрь. А при сквозном просмотре круглые скобки могут оказаться довольно далеко друг от друга.
Алгоритм Чени обходит граф объектов, в частности, для обычных узлов он будет смотреть на ссылки вперёд и назад, для скобочных (круглых и угловых) ещё и на поле ссылки. Таким образом, при копировании будут учитываться и связи между скобками. Корнем будет ссылка на пару скобок ведущего терма вызова функции.
Также не решён вопрос: когда проводить сборку мусора? На него могут быть два ответа: при исчерпании памяти и время от времени. В первом случае мы вообще отказываемся от пула свободных узлов, все новые узлы будут выделяться подряд из нераспределённой части кучи — все выделения новых узлов будут обращаться к последовательным ячейкам памяти. Во втором случае всё как обычно, только раз в N шагов выполняется профилактическая сборка мусора.
Т.е. всего 2 × 2 = 4 варианта. Их надо проверить на практике и определить, будет ли в них толк. С одной стороны повышаем локальность данных. С другой стороны, сама отдельная процедура сборки мусора это накладной расход.
Вынесено из комментария https://github.com/Mazdaywik/Refal-05/issues/33#issuecomment-1256872395.
Актуальная реализация сопоставления с образцом сложная, костыльная и неэффективная. Она унаследована от Простого Рефала, а как правильно компилировать сопоставления с образцом, я тогда не знал.
Костыли в ней, например, в необходимости запоминать диапазоны при сопоставлениях с открытыми переменными.
Предлагается сделать сопоставления с образцом в духе диссертации Романенко — каждый распознанный элемент кладётся на стек, диапазоны открытые — это просто края уже распознанных элементов. Система команд Романенко предлагает стек времени выполнения и два «регистра» границ — следствие того, что команды интерпретируются и ради уменьшения объёма байткода (у команд меньше аргументов). Очевидно, что положение сопоставленных элементов на стеке известно во время компиляции, так что вместо стека будет простой массив ячеек и команды будут ссылаться на ячейки явным образом.
Такое представление системы команд очевидным образом допускает выделение общих префиксов — оптимизацию совместного сопоставления с образцом в её простейшем варианте. Возможно, её реализую, если это не займёт много кода.
Сопоставление с образцом в духе диссертации Романенко будет гораздо проще задокументировать — фактически нужно пересказать своими словами соответствующий раздел учебника Турчина.
Тоже самое предполагается в дальнейшем сделать и в Рефале-5λ — https://github.com/bmstu-iu9/refal-5-lambda/issues/204.
Этапы