flang язык, в котором спецификация исполняется

Замер скорости: во что нам обходится компиляция и работа

Обе правки из вопроса 3 СДЕЛАНЫ 16 августа, и замер повторён. Мы стали быстрее Python в 1,28 раза там, где были медленнее в 1,37. Разбор — в разделе «После правок» сразу за короткими ответами; остальной отчёт оставлен как был, потому что он объясняет, откуда брались числа.

Отчёт отвечает на три вопроса и ни на что больше. Короткие ответы — здесь, разбор с числами — ниже.

1. Во сколько раз мы медленнее Python и Node?

Компиляция: прямого отношения нет — у Python и Node компиляции нет вовсе, и делить на ноль нечего. Осмысленный вопрос вместо него: сколько раз надо запустить программу, чтобы наша компиляция окупилась. Ответ сегодня — никогда: мы медленнее обоих и в работе, поэтому разовая плата в 1,3 секунды (на программе замера) не возвращается ни на одном числе прогонов. Это и есть худшая новость отчёта, и она названа прямо. После двух правок из вопроса 3 компиляция начинает окупаться против Python на трёх задачах из пяти (за 6, 13 и 20 прогонов), против Node — по-прежнему ни на одной. Подробности — в разделе «Время компиляции».

Работа: медленнее Python в 1,4 раза и медленнее Node в 3,3 раза (геометрическое среднее по пяти задачам). По задачам разброс большой: от 1,04 (НОД) до 2,2 (дерево) против Python и от 2,2 (строки) до 10,3 (Коллатц) против Node. Написанный руками C быстрее нас в 8,6 раза.

2. Какая часть этого — цена доказуемости, а какая — недоделанность?

Ответ распадается надвое, и обе половины важны.

То есть: доказуемость дорога ровно там, где её применили, и почти не применена. Взвешенно по корпусу цена доказуемости — единицы процентов; недоделанность — всё остальное, то есть 90 с лишним процентов отставания.

3. Что дало бы наибольший выигрыш за наименьшую работу?

правкаоценкавыигрышцена
-flto в порождаемом Makefile2 строки (emit/c.mjs и self/emit-c.flang)1,03–1,60×сборка дольше вдвое
не печатать проверку тега там, где тип уже доказан тайпчекером150–250 строк: тайпчекер должен отдавать тип на узел, чего он сейчас не делаетещё 1,09–3,15×
вместе1,8× геометрическим средним, до 5× на арифметике

Третья, дороже всех и важнее всех по памяти: арена. «Сортировка вставками» — тотальная, доказанная, написанная прямо — на 1 000 элементах занимает 5,6 ГиБ, а на 4 000 набрала 178 ГиБ и не досчитала. Это уже не правка, а работа.

Разбор с обоснованием оценок — в разделе «Что делать» в конце.


После правок: что получилось на самом деле

Обе правки сделаны (ветка work/skorost, коммиты c903983 и 2854d2b), замер повторён тем же стендом, теми же одиннадцатью кругами и тем же чередованием. Машина в этот раз была тихая: разброс 1–3 % вместо обычных 30 %, поэтому «до» и «после» ниже сняты заново оба, а не взяты из таблиц старого замера.

Время работы: три столбца

Медиана из 11 прогонов, миллисекунды, полное время процесса.

задачадопосле флагапосле обеихво сколько раз быстрее
коллатц (50 000)596,8396,2132,54,50
нод (300 000)149,6153,478,61,90
сортировка (100 000)240,9201,7178,81,35
строки (18 удвоений)441,8398,5334,91,32
дерево (100 000)635,5637,9556,41,14
геометрическое среднее1,77

Разброс «после»: коллатц [131,4 … 145,6], нод [76,5 … 79,5], сортировка [174,6 … 183,1], строки [318,7 … 344,7], дерево [539,7 … 594,6].

Кто из двух правок сколько дал

правкагеометрическое среднее
флаг -flto в порождаемом Makefile (2 строки)1,14
считать доказанную арифметику выражением (~90 строк)ещё 1,55
вместе1,77

Флаг на двух задачах из пяти не дал ничего: НОД 0,98 и дерево 1,00 — обе цифры внутри разброса. Прежний замер обещал флагу 1,60 на НОД; на сегодняшнем дереве этого нет, и говорится об этом прямо. Всё, что флаг дал, он дал на коллатце (1,51), сортировке (1,19) и строках (1,11).

Обгоняем ли мы теперь Python

противбылостало
Python1,37 медленнее0,78 — то есть быстрее в 1,28 раза
Node3,11 медленнее1,81 медленнее
написанного руками C7,97 медленнее4,52 медленнее

По задачам против Python: коллатц 0,41, нод 0,63, сортировка 0,71, строки 0,77 — быстрее на четырёх из пяти. Дерево 2,02 остаётся медленнее вдвое, и причина названа в разделе «Где мы медленнее и почему»: узел варианта занимает 144 байта против 24 у структуры C. Это ширина значения, а не проверки, и снимается она третьей правкой, которая не сделана.

Цена по времени сборки

Здесь ожидание не подтвердилось, и это стоит сказать отдельно.

что собирается-O2-O2 -flto
точка самораскрутки, 6,2 МБ порождённого C, make -j438,7 с17,7 с
программа замера, 432 строки flang0,8 с1,4 с

На большом файле сборка стала вдвое быстрее, а не вдвое медленнее: с -flto шаг cc -c перестаёт оптимизировать и дешевеет, а оптимизация уезжает на компоновку, где gcc режет программу на разделы и обрабатывает их параллельно. На маленькой программе разделов один, и плата за компоновку видна. Поэтому флаг включён умолчанием, а не спрятан в отдельную строку; кому нужна быстрая сборка — make CFLAGS='-std=c99 -O2', и это написано в самом Makefile.

Вторая правка платит вторым проходом проверки типов на переднем крае: отметка кладётся по её выводу, а раньше этот проход почти всегда пропускался (арифметика есть почти в каждой программе, а частичные формы — редко). Замерено чередованием двух деревьев, 7 кругов, медиана полного времени процесса flang emit c:

файлдо, мспосле, мсдороже
leetcode/509-fibonacci-number.flang136,1136,50,3 %
stdlib/lists.flang174,3174,80,3 %
self/lexer.flang336,1338,90,8 %
self/types.flang (самый большой)974,2991,21,7 %

Семнадцать миллисекунд на самом большом файле — против семнадцати секунд, которые на нём же тратит cc.

Изъятие: снять правку — замер обязан вернуться

Правило репозитория: правка, от снятия которой ничего не меняется, ничего и не держит. Проверено тремя сборками ОДНОЙ программы, чередующимися внутри круга, 9 кругов:

задачас обеими правкамибез отметки типабез обеих (origin/main)
коллатц134,9411,1607,7
нод79,9162,3152,7
строки335,7421,3447,4

Снятие отметки возвращает коллатц ровно на уровень «только -flto» (411 против 396 в отдельном замере — разница внутри разброса). Ответы у всех трёх сборок на всех задачах одинаковы: по одному различному значению на задачу.

Что осталось прежним


Как мерили

Машина. AMD EPYC 7742, два сокета, 64 ядра на сокет, 256 логических процессоров, 499 ГиБ памяти, Linux 7.0.0-29-generic.

Версии.

инструментверсия
python33.14.4 (собран GCC 15.2.0)
nodev26.7.0 (V8 14.6.202.34)
ccUbuntu 15.2.0-16ubuntu1 (gcc 15.2.0)
tscнет в системе

tsc не измерен и не оценён. В репозитории нет каталога node_modules, глобально установлены только npm и corepack. Ставить TypeScript ради замера значило бы мерить не ту машину, на которой всё остальное; сказать «примерно столько же, сколько…» значило бы выдать догадку за прогон.

Шум, и его здесь много. Машина занята другими работами: во время замеров средняя загрузка держалась между 100 и 190, и в списке процессов постоянно висели чужие счётные задачи по 100–260 % ЦП каждая. Поэтому:

Все числа — прогоны. В отчёте нет ни одной оценки. Там, где инструмента не оказалось, стоит «нет в системе», а не приблизительное значение.

Что мерили: пять задач, записанных трижды

Задачи выбраны так, чтобы они честно переносились на все три языка и вместе покрывали разные виды работы:

задачачто делаетразмерчто нагружает
коллатцсумма длин цепочек Коллатца для 1…NN = 50 000только арифметика и вызовы, ни одной ячейки памяти под данные
нодсумма НОД(i, 40902) для 1…N по ЕвклидуN = 300 000то же, но доказательство здесь держится на объявленной мере — единственная задача со сторожем в рантайме
сортировкасортировка слиянием N псевдослучайных чиселN = 100 000списки, много промежуточных значений
деревовставка N ключей в дерево поиска, потом сумма и глубинаN = 100 000суммы типов, неизменяемые структуры, настоящая рекурсия
строкитекст удваивается 18 раз, делится по запятой, каждый кусок — в число2 621 440 кусковстроки как данные, встроенные формы рантайма

Правило, по которому написаны все три текста: алгоритм один, а записан он так, как эту вещь пишут на данном языке. Никаких библиотечных сокращений ни у кого: ни sorted(), ни Array.prototype.sort, ни регулярных выражений, ни срезов вида lst[0::2]. Разрешены ровно те встроенные формы, которые есть у всех троих: разделение строки и перевод строки в число. Где flang печатается в цикл (хвостовой самовызов), у Python и JS стоит цикл; где flang рекурсирует по-настоящему, рекурсируют и они.

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

Сравнение проверяется, а не обещается. Все восемь сборок обязаны дать одно и то же число на каждой задаче; харнесс это сверяет и объявляет замер недействительным при расхождении. Расхождений нет ни одного.

Тексты — в benchmarks/zamer-skorosti/programs/: zadachi.flang (432 строки), zadachi.py (218), zadachi.mjs (181) и etalon.c (242). Последний — те же пять задач, написанные на C руками; без него отставание flang не разложить на «своё» и «общее для всех компилируемых языков». Полные тексты Python и JavaScript — в приложении в конце этого отчёта.

Две задачи записаны на flang ДВАЖДЫ — и это главный приём всего замера. Сортировка: обычной функцией и тотальной с топливом. НОД: обычной функцией и тотальной с объявленной мерой (убывает б), то есть со сторожем в рантайме. Одно и то же вычисление, одна и та же контрольная сумма, разница только в том, доказано ли завершение. Цена доказуемости тут не рассуждается — она вычитается.

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

сборкачто в ней иначе
flangнапечатанный C, собранный cc -O2, как он выходит из коробки
flang-счёт-выключенто же, но счётчик шагов не считает ("steps":"0" в запросе — рантайм понимает нулевой предел как «не считать»); вызов остаётся
flang-без-счётчикаfl_tick вычеркнут макросом — исчезает и сам вызов
flang-без-пределоввычеркнуты все три: fl_tick, fl_enter, fl_leave. Ни шагов, ни глубины, ни сторожа стека
flang-ltoтот же код, -flto в CFLAGS
flang-lto-без-типов-flto плюс выключенные проверки тегов в fl_add и соседях
эталон-cте же пять задач, написанные на C руками
python, nodeони же

Вычёркивание сделано макросами в файле МОДУЛЯ, а не в рантайме: сам рантайм обязан собираться обычным, иначе в нём нечего было бы звать. Все восемь сборок дают одинаковые ответы.

Время компиляции

Итог: путь от исходника до работающей программы

Медиана из 5 прогонов, миллисекунды, полное время процесса.

файлстрокфункцийиз них тотальныхflang checkflang emit cmake (cc -O2)итого
leetcode/509-fibonacci-number.flang43221231629711 133
rosetta/quicksort.flang112541251899811 170
zamer-skorosti/programs/zadachi.flang43227101442231 0741 297
stdlib/lists.flang69828281632269931 219
self/lexer.flang1 64591913754152 1882 603
self/parser.flang4 33650630067588815 14316 031
self/types.flang (самый большой)4 5485453567811 19716 99618 193

Точки сравнения на той же машине, в том же чередовании:

чтомедиана, мс
python3 -c pass25,3
node -e ""49,6
cc -O2 на etalon.c (242 строки C, написанные руками)308
cc -O2 -c на рантайме flang_runtime.c (2 109 строк)882
tscнет в системе, не измерен

python3 -c pass и node -e "" не компилируют ничего — это чистая цена запуска, и сравнивать её с нашим полным путём напрямую бессмысленно. Полезна она как мера того, сколько стоит просто добраться до кода: наш flang check на самом маленьком файле — 123 мс, из них 40–50 мс — старт Node и ещё около 50 мс — чтение 1,6 МиБ собственных модулей компилятора (parser.mjs 218 КиБ, types.mjs 266 КиБ и остальные; замерено пятью прогонами: 42, 54, 52, 59, 40 мс). То есть до первой полезной работы уходит около 100 мс, и от размера программы это не зависит вовсе.

Главное про компиляцию: платим мы не за проверки, а за cc

На самом большом файле репозитория весь наш передний край — 1 197 мс, а сборка порождённого C — 16 996 мс. Мы — 7 % пути, компилятор C — 93 %.

Почему так: 4 548 строк flang превращаются в 29 719 строк C (6,5 раза), и это не считая рантайма, который печатается к каждой программе целиком. У self/parser.flang — 4 336 строк flang → 31 486 строк C, 7,3 раза.

Из чего складываются эти 17 секунд — поштучным замером объектов:

объектстрок Ccc -O2 -c, с
flang_runtime.c (рантайм, одинаков у всех программ)2 1090,80
proverka_tipov.c (модуль самой программы)29 71911,45
flang_cli.c (прогонщик)9650,40

Сумма — 12,7 с, а make -j4 в таблице выше дал медиану 17,0 с при разбросе [10,4 … 26,1]: поштучный замер сделан отдельно и лёг к нижнему краю разброса. Шум на этой машине именно такой, и приукрашивать его нечем.

Наш C при этом дешевле обычного в пересчёте на строку: порождённый модуль — 0,39 мс/строку, рантайм — 0,38, а написанный руками etalon.c — 1,32 (компиляция без линковки; сам запуск cc стоит 0,03 с). Порождённый код многословный, но простой: оптимизировать в нём почти нечего. Семнадцать секунд берутся из объёма, а не из сложности.

Окупается ли компиляция вообще

Сравнивать нашу компиляцию с python3 -c в разах бессмысленно: у Python и Node её нет. Осмысленное сравнение — сколько прогонов нужно, чтобы разовая плата за компиляцию вернулась выигрышем в работе.

Программа замера (zadachi.flang, 432 строки) компилируется за 1 297 мс (emit 223 + make 1 074). Дальше каждый прогон дешевле или дороже, чем у соперника, — и вот на сколько:

задачавыигрыш flang за прогон против Pythonокупаемостьпротив Nodeокупаемость
коллатц−262 мсникогда−579 мсникогда
нод−7 мсникогда−93 мсникогда
сортировка−45 мсникогда−217 мсникогда
дерево−415 мсникогда−471 мсникогда
строки−74 мсникогда−293 мсникогда

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

Как это выглядит после двух правок из раздела «Что делать» (сборка flang-lto-без-типов):

задачавыигрыш за прогон против Pythonокупаемость
коллатц+249 мс6 прогонов
строки+107 мс13 прогонов
нод+68 мс20 прогонов
сортировка+6 мс217 прогонов
дерево−272 мсникогда

Против Node не окупается по-прежнему ни одна: на всех пяти задачах Node остаётся быстрее даже исправленного flang (от −19 мс на НОД до −328 на дереве).

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

Разложение переднего края по шагам

Внутри одного процесса, медиана из 7 прогонов, миллисекунды. Первый прогон в процессе («холодный») стоит в 1,5–4 раза дороже — движок ещё не видел этого кода; для пользователя, который зовёт flang один раз, настоящее время именно холодное, и оно приведено отдельной строкой.

шагfib (43)quicksort (112)zadachi (432)lists (698)lexer (1 645)types (4 548)
лексер0,391,864,875,1721,932,3
разбор (без лексера)0,230,701,873,473,6415,8
связывание модулей0,260,122,841,3748,4237,8
проверка типов0,320,521,121,263,519,1
анализ тотальности0,080,130,290,502,4215,2
законы (моноид, монада, изо, множества)0,020,020,020,020,050,05
обязательства0,030,030,040,030,130,43
проверка доказательств (ядро)0,020,030,030,020,050,10
генератор кода C5,167,1713,911,736,9115,5
…из них постоянная часть4,674,924,773,984,303,76
всего (горячий)12,020,140,445,9174,3520,4
всего (холодный, первый в процессе)48,870,286,198,1397,0803,6

Что здесь видно.

Доказательства не стоят почти ничего. На самом большом файле анализ тотальности, обязательства, ядро доказательств и законы вместе — 15,8 мс из 520 мс переднего края, то есть 3 %. А от полного пути до работающей программы (18 193 мс) это 0,09 %. Проверка предъявленных доказательств (proofterm.mjs) — 0,10 мс.

Дорого — разбор. Лексер плюс разбор плюс связывание (а связывание это тот же разбор, только импортированных файлов) — 286 мс из 520, 55 %. Дальше генератор кода — 115 мс, 22 %.

Постоянная часть генератора — 4 мс на любую программу. Это чтение рантайма (9 628 строк C) с диска и проверка его на сырые двунаправленные управляющие символы при каждом вызове. От размера программы не зависит.

Рост нелинейный, но умеренный: от 43 строк до 4 548 (в 106 раз) передний край вырос с 12 до 520 мс, то есть в 43 раза. По функциям — с 2 до 545 (272 раза) при том же росте времени в 43 раза.

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

Время работы

Медиана из 11 прогонов, миллисекунды, полное время процесса — от запуска до ответа. Разделять «запуск» и «счёт» здесь нечестно: у собранного бинарника запуск почти бесплатен, а у Python и Node он стоит заметных миллисекунд, и это их настоящее свойство. Пустая задача (запуститься и ничего не сделать) стоит: flang 5,4, эталон на C 4,1, python 18,7, node 40,5 мс.

задачаflangpythonnodeэталон C
коллатц (50 000)640,7378,462,220,5
нод (300 000)156,5149,963,248,9
сортировка (100 000)341,3296,2124,418,9
дерево (100 000)764,2348,9292,9130,8
строки (18 удвоений)547,4473,7254,4120,3

Во сколько раз медленнее:

задачаflang / pythonflang / nodeflang / эталон C
коллатц1,6910,331,3
нод1,042,483,2
сортировка1,152,7418,1
дерево2,192,615,8
строки1,162,154,6
геометрическое среднее1,393,308,64

Разброс (минимум … максимум по 11 прогонам) доходит до 30 % — например, у «дерева» flang дал [643 … 963]. Поэтому таблица выше говорит о разах, а не о процентах, и все выводы ниже опираются на разницы больше разброса.

Где мы медленнее и почему

Коллатц — худшая наша задача, и она же самая показательная. Ни одной ячейки памяти под данные, только арифметика и вызовы: 10,3 раза против Node и 31 раз против C. Причина видна прямо в порождённом коде — вот тело цикла:

for (;;) {
  FL_TRY(fl_tick(ctx, "Шагов Коллатца", error));
  fl_value fl_t16 = fl_nothing();
  FL_TRY(fl_lte(ctx, n, fl_number(1.0), &fl_t16, error));
  bool fl_t17 = false;
  FL_TRY(fl_cond(ctx, fl_t16, &fl_t17, error));
  ...
      FL_TRY(fl_div(ctx, n, fl_number(2.0), &fl_t20, error));

Каждое n делить на 2 — это вызов функции из другой единицы трансляции, которая берёт два значения по 32 байта, проверяет у обоих тег, делит два double, кладёт результат по указателю и возвращает статус. Компилятор cc подставить её на место не может: рантайм лежит в отдельном .c, а межмодульная оптимизация не включена. sizeof(fl_value) — **32 байта против 8 у double**.

Дерево — вторая по тяжести (2,2 раза против Python), и там же худшая память. Узел дерева в flang — это fl_variant (24 байта) плюс три fl_field по 40 байт = 144 байта на узел против 24 байт у структуры на C. Отсюда и таблица памяти.

НОД и строки почти догоняют Python (1,04 и 1,16) — и по понятной причине: в НОД внутренний цикл короткий (среднее число витков Евклида — единицы), а в строках заметная часть работы уходит на встроенные формы рантайма (разделение пятнадцатимегабайтной строки, перевод куска в число), то есть на обычный компилированный C, а не на порождённый.

Пиковая память

/usr/bin/time, максимальный резидентный размер, МиБ:

задачаflangэталон Cpythonnode
коллатц (50 000)6,16,18,152,6
нод (300 000)6,16,18,152,6
сортировка (100 000)1926,116,178,7
дерево (100 000)3306620,196,7
строки (18)14010138,297,8

Где данных нет (коллатц, НОД), мы ровно на уровне C. Где данные есть — мы в 12 раз тяжелее Python на сортировке и в 16 раз на дереве. Это арена: она не отдаёт ничего до конца вызова, поэтому каждая промежуточная копия остаётся лежать.

Арена: там, где это уже не «в разы», а «не работает»

Взята «Сортировка вставками» из flang/examples/rosetta/quicksort.flang — тотальная, доказанная структурно, написанная прямо, без единой хитрости. То есть не надуманный случай, а обычная программа на этом языке.

элементовпиковая памятьвремяитог
25080 МиБ0,15 сдосчитала
500604 МиБ0,99 сдосчитала
7502,0 ГиБ3,88 сдосчитала
1 0005,6 ГиБ9,68 сдосчитала
1 500**отказ FLANG_MEMORY** (при пределе 8 ГиБ)

Восемь-девять раз на удвоение входа, то есть рост кубический. Сверх таблицы: на 4 000 элементах эта же программа набрала 178 ГиБ и не досчитала за две с половиной минуты — процесс пришлось снять, чтобы не уронить машину.

Алгоритм здесь квадратичен по времени, и это нормально; кубическая ПАМЯТЬ — целиком следствие двух решений рантайма: арена не освобождает промежуточное, а «Приписать в начало» у списка-массива копирует. Оба решения объяснены в шапке flang_runtime.h честно и обоснованно — но цена у них такая.

Что из этого цена доказуемости, а что недоделанность

Здесь три отдельных прибора, и каждый мерит свою вещь вычитанием.

Прибор 1: счётчик шагов, счётчик глубины, сторож стека

Это то, без чего объявленные пределы (FLANG_RECURSION_LIMIT) перестали бы быть пределами. Вычеркнуто тремя способами — от «не считать» до «убрать вызовы целиком»:

задачаflangсчёт выключенfl_tick убранвсе пределы убраныразброс базовой
коллатц640,7652,4639,1630,0[617 … 699]
нод156,5154,4153,5151,8[151 … 171]
сортировка341,3333,8347,7338,5[316 … 403]
дерево764,2699,0746,4733,4[643 … 963]
строки547,4521,3542,2509,2[485 … 655]

Разница между первым и последним столбцом — 0,9 … 8,9 %, и она целиком внутри разброса. Что это именно шум, видно по двум местам, где вычёркивание работы сделало программу МЕДЛЕННЕЕ: на коллатце сборка с выключенным счётом дала 652 мс против 641 у базовой, на сортировке сборка с вычеркнутым fl_tick — 348 против

  1. Физически такого быть не может; значит и разницы в другую сторону этот

прибор на таких величинах не различает.

Вывод: счётчики стоят неотличимо от нуля. Их убирание не купило бы ничего, а стоило бы объявленных гарантий.

Отдельная заметка на будущее. Счётчик шагов нужен обычным функциям: они вправе не завершаться, и без него напечатанная программа крутилась бы вечно вместо FLANG_RECURSION_LIMIT. У тотальной функции завершение доказано, и счётчик там логически избыточен — но fl_tick печатается ей ровно так же (в zadachi.flang его несут и «Через один», и «Слить с топливом», обе доказанные). Это несогласованность, а не расход: убрать её значит выиграть ноль. Приводится потому, что «доказано, а всё равно проверяем» — единственное такое место, найденное замером, и оно ничего не стоит.

Доля в превышении над написанным руками C:

задачапревышение над Cиз него пределы
коллатц620 мс1,7 %
нод108 мс4,4 %
сортировка322 мс0,9 %
дерево633 мс4,9 %
строки427 мс8,9 %

Прибор 2: сторож объявленной меры — и вот он дорог

Единственное место, где доказательство оставляет настоящий след внутри работающей программы. Автор пишет убывает б, и компилятор ставит на каждый рекурсивный вызов проверку: мера строго убыла, не ушла ниже нуля, осталась целой. Один и тот же Евклид, записанный дважды:

сборкаНОД обычныйНОД доказанныйво сколько раз дороже
flang156,5479,13,06
flang-lto97,7390,74,00
flang-lto-без-типов82,2160,81,96

Доказательство утроило время функции. Это честная цена, а не изъян: без него тотальная на IEEE-754 была бы ложным обещанием (остатки пары (φ, 1) не кончаются никогда), и сторож переводит ложь в отказ FLANG_MEASURE. Но цена именно такая, и её надо знать.

Считая от эталона на C: доказанный НОД превышает его на 430 мс (479,1 − 48,9), и из этих 430 мс 322 мс — сторож (479,1 − 156,5), то есть 75 %. Остальные 25 % — та же недоделанность, что и везде.

Прибор 3: приём «топливо»

Сортировка слиянием, записанная обычной функцией и тотальной с топливом. Тот же алгоритм, тот же ответ:

сборкасортировка обычнаясортировка с топливомдороже на
flang341,3455,5+33 %
flang-lto315,2361,9+15 %

Топливо здесь — не украшение подписи: доказанная запись на каждом уровне склеивает две половины в список-топливо для слияния, и это лишний проход по всем данным.

Насколько всё это распространено в корпусе

Свод npm run proof:ledger по 162 файлам, 2 799 функциям:

чем несётся обещание «тотальная»функцийсторож в рантайме
композицией (рекурсии нет)1 907нет
структурой (часть значения)205нет
точным шагом («нат»)18нет
постоянным шагом (число)2есть
объявленной мерой64есть
итого тотальных2 196сторожей: 100 мест у 66 функций
обычных (о завершении не сказано ничего)603

Параметр-топливо встречается в корпусе у 5 функций (fibonacci, 704-binary-search, primes-by-trial-division и две в merge-sort).

Итого 71 функция из 2 799 — 2,5 % платит за доказуемость что-то измеримое. Остальные 97,5 % платят счётчиками, а те неотличимы от нуля.

Что остаётся: недоделанность

Всё, что не объяснено выше, — а это от 91 % (строки) до 99 % (сортировка) превышения над написанным руками C. Разбирается она тем же вычитанием:

задачаflang+-flto+без проверок теговэталон C
коллатц640,7407,3129,420,5
нод156,597,782,248,9
сортировка341,3315,2290,218,9
дерево764,2741,9621,1130,8
строки547,4419,3366,6120,3

Четыре причины, в порядке измеренного вклада:

1. Межмодульной оптимизации нет. Рантайм и модуль программы лежат в разных единицах трансляции, поэтому cc не может подставить fl_add на место вызова. Один флаг -flto даёт 1,03–1,60 раза.

2. Проверки типов делаются дважды. fl_add перед каждым сложением смотрит теги обоих значений — те самые типы, которые тайпчекер уже доказал на исходнике. Выключение этих проверок даёт ещё 1,09–3,15 раза (сверх -flto). Вместе две правки дают 1,83 раза геометрическим средним и до 4,95 раза на арифметике.

3. Значение упаковано широко. sizeof(fl_value) = 32 байта, double = 8. Узел варианта с тремя полями = fl_variant (24) + 3 × fl_field (40) = 144 байта против 24 у структуры на C. Это же объясняет и память: сортировка 192 МиБ против 6 у C, дерево 330 против 66.

4. Арена не отдаёт ничего до конца вызова. Отдельно от упаковки: даже узкие значения, положенные в арену, останутся лежать. Кубический рост на «Сортировке вставками» — целиком отсюда.

Причины 3 и 4 — это уже не флаг и не двести строк; это перестройка представления значений и памяти рантайма, то есть отдельная работа. Но и она относится к недоделанности, а не к гарантиям: ни одно обещание языка от ширины fl_value не зависит.

Ответ одним абзацем

На 97,5 % функций корпуса доказуемость стоит неотличимо от нуля и по компиляции (3 % переднего края, 0,09 % полного пути), и по работе (1–9 %, и всё это внутри разброса). На оставшихся 2,5 % она стоит дорого и честно: сторож объявленной меры утраивает функцию, топливо добавляет треть. Всё прочее отставание — недоделанность. Из восьмикратного разрыва с написанным руками C две механические правки снимают 1,8 раза (геометрическим средним), и одна из них — две строки.

Что делать: выигрыш на единицу работы

1. -flto в порождаемом Makefile — две строки — СДЕЛАНО

Коммит c903983. Ниже — как рассуждали до работы; что вышло, сказано в разделе «После правок». Одна оговорка отсюда не подтвердилась: «сборка дольше примерно вдвое» верно только для маленькой программы, а на большом файле сборка стала вдвое быстрее. Поэтому флаг сделан умолчанием, а не отдельной строкой CFLAGS_RELEASE, как рекомендовалось ниже.

Сейчас порождаемый Makefile пишет CFLAGS ?= -std=c99 -Wall -Wextra -Werror -pedantic -O2. Строка живёт в двух местах, потому что бэкенд C существует в двух реализациях, и они сверяются побайтово: flang/src/emit/c.mjs:825 и flang/self/emit-c.flang:3211. Ни один тест не проверяет текст этих флагов — тесты собирают своими.

Проверено прогоном: с полным строгим набором (-Werror -pedantic) -flto собирается без единого предупреждения и даёт тот же ответ.

выигрыш во времени работы
коллатц1,57×
нод1,60×
строки1,31×
сортировка1,08×
дерево1,03×

Цена: сборка небольшой программы дольше примерно вдвое (0,79 → 1,40 с, медиана из трёх). На фоне 17 секунд make для self/types.flang это заметно, и потому разумнее сделать -flto не умолчанием, а отдельной строкой CFLAGS_RELEASE — чтобы автор выбирал между «собрать быстро» и «работать быстро».

2. Не печатать проверку тега там, где тип уже доказан — СДЕЛАНО

Коммиты 2854d2b и следующий за ним. Оценка ниже — 150–250 строк — оказалась близкой: около 100 строк кода в эталоне (types.mjs и emit/c.mjs, без комментариев) плюс около 160 в копии на самом flang, потому что отдавать типы наружу таблицей не понадобилось вовсе. В компиляторе уже был проход, который кладёт на узлы дерева отметки анализа (доказанная непустота, проверка убывания меры), и тип лёг туда же третьей отметкой. Верхняя граница выигрыша, обещанная ниже, взята полностью: коллатц 3,26 раза сверх -flto при обещанных 3,15.

fl_add, fl_sub, fl_mul, fl_div, fl_mod, fl_lt, fl_lte, fl_gt, fl_gte начинаются с fl_numbers, который смотрит теги обоих аргументов. Эту же работу тайпчекер уже проделал на исходнике — и выбросил результат: checkTypes возвращает { ok, diagnostics, types }, где types — только сигнатуры функций, а типов на узлах выражений нет вовсе.

Значит правка не в одну строку в case "binary" (emit/c.mjs:1248), как хотелось бы, а из двух частей: тайпчекер обязан начать отдавать выведенный тип на узел, а генератор кода — читать его и печатать fl_number(a.as.number + b.as.number) вместо вызова. Честная оценка — 150–250 строк в types.mjs и emit/c.mjs, плюс столько же в самоприменённой копии, если держать побайтовое совпадение.

Верхняя граница выигрыша измерена сборкой, где проверки выключены совсем (сверх -flto): коллатц 3,15×, дерево 1,19×, нод 1,19×, строки 1,14×, сортировка 1,09×.

Оговорка, которую пришлось дописать по итогу работы: проверку тега снять совсем НЕЛЬЗЯ, и это выяснилось прогоном корпуса. Граница входа сверяет не всё — вид неизвестно (параметр полиморфизма и значение-функция) она пропускает, потому что сверять его не с чем. Через эту дверь чужое значение доезжает до доказанного места: «Отобразить» от Удвоить и [1, "два"] со снятой проверкой отвечало 1.32e-309 вместо FLANG_TYPE — то есть отказ менялся на неверный ответ. Поэтому снят не тег, а межмодульный вызов: сверка печатается одним if в теле вызывающего, а арифметика считается выражением. Цена честности измерена: коллатц 132,5 мс вместо 121,6 без сверки — 9 %, и 2 % по геометрическому среднему.

3. Арена и ширина значения — это уже работа, а не правка

sizeof(fl_value) = 32 байта при 8 у double; узел варианта с тремя полями — 144 байта при 24 у структуры C. Арена не отдаёт ничего до конца вызова. Отсюда и 12–16-кратная память, и кубический рост на «Сортировке вставками».

Оценивать это числом строк было бы враньём: и то и другое — решения архитектуры, и оба обоснованы в шапке flang_runtime.h (одно представление вместо двух; никаких утечек на раннем возврате по ошибке). Но записать их следует как долг: ни одно обещание языка от них не зависит, то есть платится за них не доказуемость, а её отсутствие в этом месте.

Чего делать НЕ надо

Убирать счётчик шагов, счётчик глубины или сторож стека. Замер показал, что все трое вместе стоят меньше разброса между прогонами, а держат они объявленный предел, без которого напечатанная программа в C падала бы по SIGSEGV вместо FLANG_RECURSION_LIMIT. Это единственное место отчёта, где ответ — «оставить как есть», и он подкреплён тремя независимыми абляциями.

Как повторить

Все скрипты лежат в benchmarks/zamer-skorosti/. Ни один не трогает репозиторий: всё собирается в указанный каталог.

# 1. Собрать восемь вариантов одной программы плюс эталон на C
benchmarks/zamer-skorosti/sobrat.sh /tmp/zamer

# 2. Время работы: пять задач, восемь сборок, чередование, 11 кругов
node benchmarks/zamer-skorosti/rabota.mjs /tmp/zamer --кругов 11

# 3. Пиковая память тех же задач
node benchmarks/zamer-skorosti/pamyat.mjs /tmp/zamer

# 4. Рост арены на «Сортировке вставками» (с пределом адресного пространства)
mkdir -p /tmp/zamer/qs
node flang/bin/flang.mjs emit flang/examples/rosetta/quicksort.flang \
  --target c --out /tmp/zamer/qs --max-steps 2000000000
make -C /tmp/zamer/qs -j4
benchmarks/zamer-skorosti/arena.sh /tmp/zamer/qs

# 5. Время компиляции: итоги процессами, вместе с точками сравнения
node benchmarks/zamer-skorosti/kompilyaciya.mjs --кругов 5

# 6. Время компиляции: слагаемые внутри одного процесса
node benchmarks/zamer-skorosti/faz.mjs flang/self/types.flang --повторов 7

# 7. Свод по корпусу: чем несётся обещание «тотальная» и где сторожа
npm run proof:ledger

Разовая проба одной задачи на трёх языках, с временем и памятью:

benchmarks/zamer-skorosti/probe.sh /tmp/zamer/base/flang_cli "Обход дерева" дерево 100000

**Перед node в ручных прогонах ставьте LC_ALL=C.UTF-8** — имена задач записаны кириллицей, и без этого аргументы приезжают побитыми. Харнессы ставят её сами.

Приложение: тексты на Python и JavaScript

Приложены целиком, чтобы сравнение можно было проверить, а не принять на веру. Текст на flang — benchmarks/zamer-skorosti/programs/zadachi.flang (432 строки), эталон на C — benchmarks/zamer-skorosti/programs/etalon.c (242 строки).

zadachi.py

# SPDX-FileCopyrightText: 2026 Digitable (Marat Zimnurov)
# SPDX-License-Identifier: BSD-2-Clause
"""Те же четыре задачи, что в zadachi.flang, на Python 3.

Правило перевода — шаг в шаг:
  • где flang печатается в цикл (хвостовой самовызов) — здесь цикл;
  • где flang рекурсирует по-настоящему — здесь рекурсия;
  • никаких библиотечных сокращений: ни sorted(), ни срезов вида lst[0::2].
    Разрешены ровно те встроенные формы, которые есть и у flang: разделение
    строки (str.split ↔ «разделить … по …») и перевод строки в число
    (int ↔ «к числу»).

Запуск:  python3 zadachi.py ЗАДАЧА РАЗМЕР
         python3 zadachi.py коллатц 20000
Печатает одно число — ту же контрольную сумму, что и остальные два языка.
"""
import sys

A = 25173
C = 13849
M = 65536


def chisla(skolko, zerno):
    """«Числа»: хвостовой самовызов — значит цикл."""
    out = []
    x = zerno
    for _ in range(skolko):
        x = (A * x + C) % M
        out.append(x)
    return out


def otpechatok(elementy):
    """«Отпечаток»: свёртка."""
    acc = 0
    for e in elementy:
        acc = (acc * 31 + e) % 1000003
    return acc


# ── задача 1: счёт на числах ────────────────────────────────────────────────


def shagov_kollatca(n):
    """«Шагов Коллатца»: хвостовой самовызов — цикл."""
    nabrano = 0
    while n > 1:
        n = n // 2 if n % 2 == 0 else 3 * n + 1
        nabrano += 1
    return nabrano


def kollatc(predel):
    """«Сумма Коллатца»: тоже хвостовой, но вызов внутрь — настоящий."""
    summa = 0
    tekushchee = 1
    while tekushchee <= predel:
        summa += shagov_kollatca(tekushchee)
        tekushchee += 1
    return summa


# ── задача 1-бис: НОД ───────────────────────────────────────────────────────
# У flang эта задача записана дважды — с объявленной мерой (и сторожем в
# рантайме) и без неё. Здесь запись одна: понятия «доказано» у Python нет, и
# изображать его было бы подлогом. Ответ у всех записей один.


def nod(a, b):
    while b != 0:
        a, b = b, a % b
    return a


def nod_zadacha(predel):
    summa = 0
    tekushchee = 1
    while tekushchee <= predel:
        summa += nod(tekushchee, 40902)
        tekushchee += 1
    return summa


# ── задача 2: сортировка слиянием ───────────────────────────────────────────


def cherez_odin(elementy, s):
    """«Через один»: каждый второй, начиная с позиции s."""
    out = []
    i = s
    n = len(elementy)
    while i < n:
        out.append(elementy[i])
        i += 2
    return out


def sliyanie(pervyy, vtoroy):
    """«Слияние»: хвостовой самовызов — цикл с двумя указателями."""
    out = []
    i = 0
    j = 0
    n = len(pervyy)
    m = len(vtoroy)
    while i < n and j < m:
        if pervyy[i] <= vtoroy[j]:
            out.append(pervyy[i])
            i += 1
        else:
            out.append(vtoroy[j])
            j += 1
    while i < n:
        out.append(pervyy[i])
        i += 1
    while j < m:
        out.append(vtoroy[j])
        j += 1
    return out


def sortirovka(elementy):
    """«Сортировка»: рекурсия настоящая — значит и здесь рекурсия."""
    if len(elementy) <= 1:
        return elementy
    levaya = cherez_odin(elementy, 0)
    pravaya = cherez_odin(elementy, 1)
    return sliyanie(sortirovka(levaya), sortirovka(pravaya))


# ── задача 3: обход дерева ──────────────────────────────────────────────────
# Лист — None, узел — кортеж (ключ, слева, справа). Значения неизменяемы, как
# у flang: вставка переписывает путь и возвращает новое дерево.


def vstavit(derevo, novyy):
    if derevo is None:
        return (novyy, None, None)
    kl, l, p = derevo
    if novyy < kl:
        return (kl, vstavit(l, novyy), p)
    return (kl, l, vstavit(p, novyy))


def sobrat_derevo(skolko, zerno):
    derevo = None
    x = zerno
    for _ in range(skolko):
        x = (A * x + C) % M
        derevo = vstavit(derevo, x)
    return derevo


def summa_dereva(derevo):
    if derevo is None:
        return 0
    kl, l, p = derevo
    return (kl + summa_dereva(l)) + summa_dereva(p)


def glubina_dereva(derevo):
    if derevo is None:
        return 0
    _, l, p = derevo
    gl = glubina_dereva(l)
    gp = glubina_dereva(p)
    return (gl if gl > gp else gp) + 1


def obhod_dereva(skolko):
    derevo = sobrat_derevo(skolko, 12345)
    return summa_dereva(derevo) + 1000000 * glubina_dereva(derevo)


# ── задача 4: разбор строк ──────────────────────────────────────────────────


def udvoit_tekst(tekst, raz):
    for _ in range(raz):
        tekst = tekst + "," + tekst
    return tekst


def razbor_strok(raz):
    tekst = udvoit_tekst("17,42,8,99,3,71,25,60,14,88", raz)
    acc = 0
    for kusok in tekst.split(","):
        acc = (acc * 31 + int(kusok)) % 1000003
    return acc


# ── точки входа ─────────────────────────────────────────────────────────────


def sortirovka_zadacha(skolko):
    return otpechatok(sortirovka(chisla(skolko, 12345)))


ZADACHI = {
    "коллатц": kollatc,
    "нод": nod_zadacha,
    "сортировка": sortirovka_zadacha,
    "дерево": obhod_dereva,
    "строки": razbor_strok,
}


def main(argv):
    if len(argv) != 3 or argv[1] not in ZADACHI:
        sys.stderr.write("использование: zadachi.py {%s} РАЗМЕР\n" % "|".join(ZADACHI))
        return 2
    sys.setrecursionlimit(200000)
    sys.stdout.write("%d\n" % ZADACHI[argv[1]](int(argv[2])))
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv))

zadachi.mjs

/* SPDX-FileCopyrightText: 2026 Digitable (Marat Zimnurov) */
/* SPDX-License-Identifier: BSD-2-Clause */
/**
 * Те же четыре задачи, что в zadachi.flang, на JavaScript (Node.js).
 *
 * Правило перевода — шаг в шаг, как и у zadachi.py:
 *   • где flang печатается в цикл (хвостовой самовызов) — здесь цикл;
 *   • где flang рекурсирует по-настоящему — здесь рекурсия;
 *   • никаких библиотечных сокращений: ни Array.prototype.sort, ни filter по
 *     индексу. Разрешены ровно те встроенные формы, которые есть и у flang:
 *     String.prototype.split (↔ «разделить … по …») и Number (↔ «к числу»).
 *
 * Запуск:  LC_ALL=C.UTF-8 node zadachi.mjs ЗАДАЧА РАЗМЕР
 * Печатает одно число — ту же контрольную сумму, что и остальные два языка.
 */
const A = 25173
const C = 13849
const M = 65536

function chisla(skolko, zerno) {
  const out = []
  let x = zerno
  for (let i = 0; i < skolko; i += 1) {
    x = (A * x + C) % M
    out.push(x)
  }
  return out
}

function otpechatok(elementy) {
  let acc = 0
  for (const e of elementy) acc = (acc * 31 + e) % 1000003
  return acc
}

/* ── задача 1: счёт на числах ─────────────────────────────────────────────── */

function shagovKollatca(n) {
  let nabrano = 0
  while (n > 1) {
    n = n % 2 === 0 ? n / 2 : 3 * n + 1
    nabrano += 1
  }
  return nabrano
}

function kollatc(predel) {
  let summa = 0
  for (let tekushchee = 1; tekushchee <= predel; tekushchee += 1) {
    summa += shagovKollatca(tekushchee)
  }
  return summa
}

/* ── задача 1-бис: НОД ────────────────────────────────────────────────────── */
/* У flang эта задача записана дважды — с объявленной мерой (и сторожем в
   рантайме) и без неё. Здесь запись одна: понятия «доказано» у JavaScript нет,
   и изображать его было бы подлогом. Ответ у всех записей один. */

function nod(a, b) {
  while (b !== 0) {
    const ost = a % b
    a = b
    b = ost
  }
  return a
}

function nodZadacha(predel) {
  let summa = 0
  for (let tekushchee = 1; tekushchee <= predel; tekushchee += 1) summa += nod(tekushchee, 40902)
  return summa
}

/* ── задача 2: сортировка слиянием ────────────────────────────────────────── */

function cherezOdin(elementy, s) {
  const out = []
  for (let i = s; i < elementy.length; i += 2) out.push(elementy[i])
  return out
}

function sliyanie(pervyy, vtoroy) {
  const out = []
  let i = 0
  let j = 0
  while (i < pervyy.length && j < vtoroy.length) {
    if (pervyy[i] <= vtoroy[j]) {
      out.push(pervyy[i])
      i += 1
    } else {
      out.push(vtoroy[j])
      j += 1
    }
  }
  while (i < pervyy.length) {
    out.push(pervyy[i])
    i += 1
  }
  while (j < vtoroy.length) {
    out.push(vtoroy[j])
    j += 1
  }
  return out
}

function sortirovka(elementy) {
  if (elementy.length <= 1) return elementy
  const levaya = cherezOdin(elementy, 0)
  const pravaya = cherezOdin(elementy, 1)
  return sliyanie(sortirovka(levaya), sortirovka(pravaya))
}

/* ── задача 3: обход дерева ───────────────────────────────────────────────── */
/* Лист — null, узел — массив [ключ, слева, справа]. Значения не правятся:
   вставка переписывает путь и возвращает новое дерево, как у flang. */

function vstavit(derevo, novyy) {
  if (derevo === null) return [novyy, null, null]
  const [kl, l, p] = derevo
  if (novyy < kl) return [kl, vstavit(l, novyy), p]
  return [kl, l, vstavit(p, novyy)]
}

function sobratDerevo(skolko, zerno) {
  let derevo = null
  let x = zerno
  for (let i = 0; i < skolko; i += 1) {
    x = (A * x + C) % M
    derevo = vstavit(derevo, x)
  }
  return derevo
}

function summaDereva(derevo) {
  if (derevo === null) return 0
  return derevo[0] + summaDereva(derevo[1]) + summaDereva(derevo[2])
}

function glubinaDereva(derevo) {
  if (derevo === null) return 0
  const gl = glubinaDereva(derevo[1])
  const gp = glubinaDereva(derevo[2])
  return (gl > gp ? gl : gp) + 1
}

function obhodDereva(skolko) {
  const derevo = sobratDerevo(skolko, 12345)
  return summaDereva(derevo) + 1000000 * glubinaDereva(derevo)
}

/* ── задача 4: разбор строк ───────────────────────────────────────────────── */

function udvoitTekst(tekst, raz) {
  for (let i = 0; i < raz; i += 1) tekst = tekst + "," + tekst
  return tekst
}

function razborStrok(raz) {
  const tekst = udvoitTekst("17,42,8,99,3,71,25,60,14,88", raz)
  let acc = 0
  for (const kusok of tekst.split(",")) acc = (acc * 31 + Number(kusok)) % 1000003
  return acc
}

/* ── точки входа ──────────────────────────────────────────────────────────── */

const ZADACHI = {
  "коллатц": kollatc,
  "нод": nodZadacha,
  "сортировка": (skolko) => otpechatok(sortirovka(chisla(skolko, 12345))),
  "дерево": obhodDereva,
  "строки": razborStrok,
}

const [, , zadacha, razmer] = process.argv
if (zadacha === undefined || ZADACHI[zadacha] === undefined) {
  process.stderr.write(`использование: node zadachi.mjs {${Object.keys(ZADACHI).join("|")}} РАЗМЕР\n`)
  process.exit(2)
}
process.stdout.write(`${ZADACHI[zadacha](Number(razmer))}\n`)