flang компилятор доказывает, что программа не зациклится 0.6.2 GitHub

Сколько процессов тянет планировщик flang

Миллион работающих процессов планировщик держит. Прогон заводит их за 11,6 секунды и 1,7 ГиБ, доставка при этом стоит 0,492 мкс, переключение при 960 000 одновременно готовых — около 11 мкс. Четыре миллиона тоже проверены: 39,87 секунды и 6 744 МиБ.


Что мерили

Модель конкурентности лежит в flang/conc/SPEC.md (210 КБ), flang/conc/RESILIENCE.md, flang/conc/DISTRIBUTED.md. Есть свидетельский планировщик на JavaScript и три напечатанных:

ЦельФайлЧто это
Cflang/src/emit/c/flang_conc.c (3508 строк)свой планировщик; рабочий режим — потоки берут процессы, у каждого поток свой склад готовых
Elixirflang/src/emit/elixir/flang_conc.exпроцесс flang = процесс BEAM (use GenServer)
JavaScriptflang/src/emit/js/flang_conc.js (611 строк)свой планировщик, один поток

Остальные пять целей из восьми (Go, Rust, Python, Java, C#) программу с процессами печатать отказываются — код FLANG_CONC_UNSUPPORTED.

Процессы заводятся на ходу: слово породить есть в модели и в цели C (flang/src/emit/c/flang_conc.c:1390), у целей JavaScript и Elixir его нет. Поэтому «сколько процессов тянет планировщик» и «сколько процессов можно завести» — один вопрос, а не два: исходник стенда «рой» постоянного размера, 2 628 байт и два объявления, а миллион процессов он заводит прогоном.

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

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


Как мерили

Замеры сняты 15–17 августа 2026 на машине с 256 процессорами и 499 ГБ памяти: gcc 15.2.0, Erlang/OTP 29 (erts-17.0.5, JIT), Elixir на нём же.

Машина всё время была занята чужой работой. Средняя нагрузка за минуту держалась между 125 и 734, на замерах пула гуляла от 60 до 1250 — то есть местами машина была перегружена втрое. Поэтому:

Все числа времени — верхние оценки. На свободной машине они меньше.

Стенды для замера лежат в flang/conc/bench/, а работать надо в пустом рабочем каталоге: на миллионе объявленных процессов стенд — это 166 МБ исходника и 129 МБ двоичника.


Числа

Молчащий процесс: около 440 байт

Стенд «тихо»: N процессов объявлено, сообщение приходит одному, прогон кончается за один пробег. Пять повторов на каждое N; разброс по повторам — единицы страниц из тысяч, то есть меньше процента везде, кроме самой мелкой строки.

ПроцессовПромахов страницБайт на процессПик RSS
2100–104ниже порога измерения
1 000202–208427ниже порога
10 0001 079–1 0814012 МиБ
100 0009 847–9 85039942 МиБ
1 000 00097 551–97 552399450 МиБ

Цена постоянна — 399 байт на слот и записи планировщика, от тысячи до миллиона. Из чего она складывается (размеры спрошены у компилятора, zamer/sizes.c): слот процесса 168 байт, плюс место в очереди готовых, признаках, дереве надзора и итоговых состояниях — 234 байта в памяти прогона; плюс 129 байт в самом двоичнике (строка имени и запись в таблице процессов).

Сверху лежит указатель имён, которым доставка ищет адресата, и он стоит 41 байт на молчащий процесс: на ста тысячах — 10 689–10 692 промаха страниц против 9 848–9 850 и пик RSS 47 140 КиБ против 43 020 КиБ. Из этих 41 восемь-десять байт сам указатель, шестнадцать — поле «каким куском покупать» в арене, остальное округление страниц. Итого около 440 байт на молчащий процесс.

Для сравнения, Erlang/OTP 29 на этой же машине: 2 648 байт на процесс (process_info(Pid, :memory), повторяется до байта), 2 651 байт по разности erlang:memory(processes) на миллионе процессов.

Работающий процесс: 1,5–1,8 КиБ

У каждого процесса своя куча, и куча из двух половин: после каждого пробега живое переезжает в свободную половину, занятая сбрасывается целиком. Сборщика мусора не нужно вовсе. Половина покупается куском в 512 байт (черновик пробега и почта остаются на 64 КиБ), и покупается в тот момент, когда процесс впервые что-то посчитал.

Стенд «ждут», 10 000 процессов, готовы всегда все, 100 000 пробегов, три повтора: пик RSS 14 336–16 384 КиБ, мелких промахов страниц 4 399–4 401, процессорное время 0,58–0,88 с. Это 1,5 КиБ на работающий процесс.

Точный прибор — счётчик malloc из valgrind, числа повторяются до байта:

СтендБайт всего
два работающих процесса207 312
прогон надзора на 20 семенах1 494 448

Адресное пространство — двоичным поиском по ulimit -v, стенд «рой», 100 000 процессов: 260 000 КиБ не хватает, 280 000 КиБ хватает — 2,87 КиБ на процесс. Тем же вторым прибором, по пиковому RSS на ста тысячах порождённых: 178 176 КиБ, то есть 1 825 байт на процесс.

Миллион работающих процессов — около трёх ГБ адресного пространства.

Точное сообщение об отказе при нехватке памяти — то самое, которое планировщик собирался сказать; на стенде «рой» под пределом ulimit -v выходит именно оно:

{"ok":false,"code":"FLANG_MEMORY","message":"кончилась память в планировщике конкурентности"}

Заведение: миллион процессов за 11,6 секунды

Стенд «рой»: один объявленный сервер порождает работников на ходу, исходник постоянного размера — 2 628 байт и два объявления, — а сколько процессов заведётся, решает предел пробегов запроса.

ПробеговЗаведено процессовПроцессорное времяПиковый RSSБайт на процесс
2 0001 0010,00 сниже порога
20 00010 0020,08 с18 МиБ1 887
200 000100 0000,95 с176 МиБ1 804
600 000300 0013,44 с571 МиБ1 950
2 000 0001 000 00011,61 с1 722 МиБ1 764
8 000 0004 000 00039,87 с6 744 МиБ1 768

Цена на процесс постоянна от ста тысяч до четырёх миллионов: 1 804 / 1 950 / 1 764 / 1 768 байта. Значит потолка здесь нет вовсе — есть память машины и предел FL_CONC_MAX_PROCESSES, который задаётся запросом.

1 764 байта на работающий процесс против 2 648 у Erlang/OTP 29.

Поправка от 20 августа: 1 948 байт, а не 1 764, и код тут ни при чём. Перепроверка тем же стендом дала 1 948 байт на процесс при миллионе и 1 948 при четырёх миллионах — три прогона подряд, разброс ноль (1 902 592 КиБ при миллионе). Число процессов и время воспроизвелись точно: 4 000 000 процессов за 39,51 с против записанных 39,87 с.

Чтобы понять, регрессия это или запись, стенд собран и померен НА ТОМ САМОМ коммите, где число 1 764 было написано (32475889, отдельная рабочая копия): 1 946 / 1 948 / 1 946 байта, то есть ровно столько же, сколько на сегодняшнем дереве. Значит код не менялся, а число не воспроизводится. Проверены и отброшены две догадки: прозрачные большие страницы (режим madvise, отключение glibc.malloc.hugetlb не меняет ничего) и предел числа процессов в запросе (2 / 4 / 8 миллионов дают 1 902 592, 1 900 544 и 1 894 400 КиБ — разброс в обратную сторону).

Вывод, который стоит записать честно: 1 764 было снято при условиях, которых сегодня не воспроизвести, и цена на процесс — около 1 950 байт. Против 2 648 байт у Erlang/OTP 29 это по-прежнему в полтора раза дешевле, и вывод таблицы не меняется; меняется третий знак.

Порождение стоит около 11,6 мкс на процесс (два пробега: сервер порождает, работник просыпается). Заведение при старте прогона, когда процессы объявлены в исходнике, дешевле — на него приходятся не три обращения к malloc (имя и две половины кучи), а ни одного лишнего:

ПроцессовВремя
100 0000,10–0,11 с
1 000 0001,05–1,11 с

Наклон: 1,04 мкс на процесс → около 960 000 процессов в секунду. Erlang на этой же машине: 200 000 spawn подряд, минимум из пяти повторов 415 938 мкс → 480 841 процесса в секунду.

Доставка: 0,492 мкс при миллионе процессов

Стенд «пингконец»: два процесса разговаривают, а объявлены они последними, поэтому адресат ищется в самом неудобном месте таблицы. Наклон между 20 000 и 120 000 пробегами, минимум из пяти повторов; строка «1 000 000» снята наклоном между 100 000 и 4 100 000.

Объявлено процессовмкс на доставку
2 (стенд «пинг»)0,477
1 0000,600
10 0000,600
100 0000,600
1 000 0000,492

Линия ровная. Миллион объявленных процессов не стоит ничего: 0,492 мкс против 0,477 мкс на двух процессах — разница внутри разброса прибора.

2,03 миллиона сообщений в секунду при миллионе процессов против 1,53 миллиона у Erlang/OTP 29 на этой же машине (миллион кругов пинг-понга, минимум 1 311 185 мкс → 762 669 кругов в секунду).

Адресат ищется указателем имён (открытая адресация, FNV-1a, номер uint32_t), который планировщик строит один раз при старте прогона и дополняет на каждом порождении. Перебор таблицы вместо указателя делает прогон квадратичным по числу процессов: двойник стенда «рой», у которого поиск заменён на цикл по таблице и всё остальное байт в байт то же самое, отстаёт в 37 раз уже на 80 000 заведённых процессах — 30,51 с против 0,82 с.

Переключение: около 11 мкс при миллионе одновременно готовых

Очередь готовых — дерево частичных сумм (Фенвика) над признаком «готов» (fl_conc_rank_add и fl_conc_select в flang/src/emit/c/flang_conc.c). «Стал готов / перестал» и «кто k-й» стоят O(log P), «сколько готовых» — O(1), памяти ровно по одному size_t на процесс.

Наружу видно не хранилище, а две функции, и обе видны в свидетеле буквально:

  1. СКОЛЬКО процессов готово — на это умножается число из семени;
  2. КТО k-й ПО ВОЗРАСТАНИЮ НОМЕРА среди готовых — его и берут.

Ни порядок в памяти, ни способ его держать наружу не видны ничем, поэтому дерево даёт побайтово тот же журнал: сверено на семенах 1, 7 и 4172, с журналом и без, и на прогоне в 1 280 000 пробегов.

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

ПробеговГотовых, примерноСекунд
20 00015 0000,11
40 00030 0000,25
80 00060 0000,54
160 000120 0001,16
320 000240 0002,60
640 000480 0005,22
1 280 000960 00013,83

При удвоении пробегов столбец множится на 2,3 / 2,2 / 2,1 / 2,2 / 2,0 / 2,7 — то есть линейно, как ему и положено. Наклон по соседним точкам — около 0,009 нс на готовый процесс. Нулём он не стал и стать не мог: дерево на миллионе узлов — восемь мегабайт, верхние его уровни в кэш не помещаются, так что O(log P) обращений — это O(log P) промахов.

Миллион одновременно готовых процессов — около 11 мкс на переключение (13,83 секунды на 1 280 000 пробегов).

Ядра: столько, сколько попросят

Число потоков задаётся полем workers в запросе. Пул не будит НИКОГО: положивший процесс на склад никого не будит, спящий поток просыпается сам и обходит склады заново.

Стенд «пачка»: N/2 пар, и внутри пары письмо уходит соседу не на каждом пробеге, а на каждом K-м; между передачами процесс пишет САМ СЕБЕ, то есть остаётся у того же потока. Один двоичник на каждое K, всё остальное — то же самое. Числа — во сколько раз рабочий режим быстрее проверочного, настенное время, минимум из пяти повторов.

Дешёвый обработчик (0,73 мкс на пробег), 4 000 000 пробегов, 1024 процесса:

K1 поток (сторож)8 потоков64 потока
10,99×2,91×1,85×
21,01×4,40×3,22×
40,86×5,75×5,20×
80,97×6,61×8,82×
161,05×7,50×14,95×
321,01×8,07×23,23×
641,00×8,47×33,91×

Первый столбец — сторож прибора: workers: 1 это тот же проверочный режим, тот же двоичник, одно лишнее поле в запросе. Он обязан дать единицу и даёт её с разбросом ±15 % — вот шум этой машины, названный числом.

Дорогой обработчик (1,80 мкс на пробег, свёртка по списку из 32 чисел), 400 000 пробегов:

K8 потоков64 потока
14,80×4,25×
25,07×7,78×
45,92×11,83×
86,64×18,00×
166,55×17,75×
327,00×24,33×
648,00×18,25×

Последняя клетка (18,25× там, где рядом 24,33×) — не находка, а предел прибора: рабочий прогон там занимает 0,03–0,04 секунды.

Порог посчитан был K > 6, измерен K = 1. Уже при одном пробеге на передачу пул быстрее в 1,85–4,80 раза, смотря по обработчику, и дальше растёт с K почти линейно.

Считанный порог брался из цены передачи работы потоку через mutex с условной переменной — 9,43 мкс на 256 ядрах против 1,76 мкс на пробег. В этом пуле такого числа просто нет: он никого не будит, поэтому мерить пришлось, а не верить.

Настоящий порог — не K, а сколько процессов готовы ОДНОВРЕМЕННО. Стенд «пинг»: два процесса перекидывают одно письмо, готов в каждый момент РОВНО ОДИН. Два миллиона пробегов, настенное время, минимум из пяти повторов:

потоковнастенное, сразпроцессорного
11,021,00×100 %
21,460,70×183 %
42,180,47×391 %
84,520,23×749 %
166,980,15×1503 %

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

Порог между крайностями измерен прямо (K = 8, восемь потоков, 2 000 000 пробегов, меняется только число процессов в стенде):

Процессов объявленоготовы одновременнораз
421,28×
1685,50×
64325,58×
2561286,38×
10245126,96×

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

Где потолок: две стенки печати и цепочка компиляции

Стенка 1 — печать сообщений прогона. Около 31 350 сообщений в одном дано. Найдено двоичным поиском, каждый шаг — настоящая печать. Массив из четырёх строк на каждое сообщение раскладывается в аргументы вызова, и на тридцати одной тысяче сообщений кончается стек печатника. Граница гуляет на пару единиц от прогона к прогону: первый поиск дал «31 349 держит, 31 350 падает», повторный — «31 348 держит, 31 349 падает», а проверочный запуск того же 31 349 прошёл. Сколько именно аргументов влезет, зависит от того, сколько стека осталось к моменту вызова, а это меняется от состояния кучи. Считать надо около 31 350, и не строить планов на «31 349 точно можно».

Стенка 2 — компилятор C на той же функции. Входные сообщения прогона печатаются одной функцией C с локальной переменной на каждое сообщение. gcc -O2 на ней растёт быстрее линейного:

Сообщений в даноВремя cc -O2
1 0007,8 с
10 000112,6 с
31 0001 302,9 с (21,7 минуты)

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

Стенка 3 — цепочка компиляции, если процессы всё же объявлять в исходнике. Миллион объявлений:

ШагВремя
исходник (166 байт на процесс)166 МБ на диске
разбор28,1–29,5 с
проверка типов3,1 с (нуль диагностик)
печать в C6,2–7,2 с
cc -O2 (54 МБ кода C)24,0–30,9 с
двоичник129 МБ

Итого около 66 секунд, то есть примерно 15 000 процессов в секунду через всю цепочку. На ста тысячах те же шаги дают 3,4 с + 0,38 с + 0,68 с + 4,4 с — всё растёт линейно. Стенд «рой» этой цепочки не касается вовсе: 2 628 байт исходника независимо от числа процессов.

Предел пробегов — умолчание, а не потолок: FL_CONC_MAX_TURNS (flang/src/emit/c/flang_conc.h:178) останавливает прогон после десяти тысяч пробегов с исходом "предел пробегов", и задаётся полем turns в запросе.

Другие цели

Elixir — единственная цель, где процесс flang становится настоящим процессом операционной системы языка: use GenServer, дерево Supervisor. Значит там работают все свойства BEAM — вытеснение, настоящая параллельность, миллион процессов по 2,7 КБ. Платится за это скоростью: тот же пинг-понг даёт около 16 000 сообщений в секунду при 222–242 % процессора, потому что его исполняет BEAM. У напечатанного Elixir каждый пробег ведёт учёт в общей таблице ETS, а прогонщик опрашивает состояние покоя раз в две миллисекунды.

JavaScript — планировщик есть (flang/src/emit/js/flang_conc.js), здесь не мерен.

Go, Rust, Python, Java, C# — процессов нет, печать отказывает кодом FLANG_CONC_UNSUPPORTED.

Что из этого складывается

ЧтоСколькоЧем ограничено
молчащих процессов1 000 000 провереноцепочкой компиляции, если объявлять в исходнике
работающих процессов, заведённых прогоном4 000 000 проверенопамятью машины и FL_CONC_MAX_PROCESSES
одновременно готовых процессов960 000 проверено
процессов, которым можно послать письмо снаружи~31 350падением печати
сообщений в секунду при миллионе процессов2,03 млн
байт на работающий процесс1 764
ядерсколько попросят (workers)числом одновременно готовых процессов

Чем это ограничено


Что менять, чтобы вышли миллионы

Осталось два пункта, и оба — в печати.

1. Убрать спред при печати сообщений (~10 строк)

В печатнике цели C массив строк на каждое сообщение раскладывается в аргументы вызова: lines.push(...body) и соседний ...inbox.map(...). Заменить на цикл. Снимает стенку в ~31 350 сообщений полностью.

2. Разбить входные сообщения на несколько функций (~30 строк)

Там же: сейчас все сообщения прогона печатаются одной функцией C, и gcc на ней захлёбывается. Резать по 500 сообщений в функцию. Снимает двадцатиминутную сборку.


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

Этот раздел — для тех, кто развивает язык. Команды ниже — команды репозитория, а не команды языка: стенды собираются и меряются скриптами из дерева flang, и путь к ним надо знать.

Стенды и скрипты — flang/conc/bench/. Локаль в среде сломана, перед node нужен LC_ALL=C.UTF-8.

Работать надо в пустом каталоге, а не в дереве: на миллионе объявленных процессов стенд это 166 МБ исходника и 129 МБ двоичника. Скрипты кладут и ищут сборки в текущем каталоге (можно переопределить ZAMER=), а само дерево находят сами.

Z=/tmp/zamer && mkdir -p $Z && cd $Z
F=/путь/к/flang/conc/bench

# 1. Собрать стенд: вид (тихо|ждут|цепь|пинг|пингконец|рой|ройждут|пары|пачка) и размеры N
bash $F/assemble.sh тихо 2 1000 10000 100000 1000000

# 2. Память покоя и время: пять повторов на стенд
bash $F/memory.sh b-тихо-2 b-тихо-1000 b-тихо-10000 b-тихо-100000 b-тихо-1000000

# 3. Цена одного пробега: наклон между двумя пределами пробегов
bash $F/assemble.sh пинг 0
bash $F/slope.sh b-пинг-0 1000000 16000000 7 0

# 4. Цена доставки против числа объявленных процессов
bash $F/assemble.sh пингконец 1000 10000 100000
bash $F/delivery.sh 20000 120000 b-пингконец-1000 b-пингконец-10000 b-пингконец-100000

# 5. Порождение: сколько процессов заводит ПРОГОН
bash $F/assemble.sh рой 1
bash $F/how-many.sh b-рой-1 2000 20000 200000 2000000

# 6. Переключение против числа ОДНОВРЕМЕННО ГОТОВЫХ
bash $F/assemble.sh ройждут 1
REP=3 bash $F/swarm.sh b-ройждут-1 20000 40000 80000 160000 320000 640000 1280000

# 7. Двойник: тот же стенд с одной изменённой строкой рантайма
bash $F/twin.sh перебор b-рой-1 b-ройперебор-1
REP=5 bash $F/swarm.sh b-ройперебор-1 20000 40000 60000 80000 160000

# 8. Точные байты malloc на процесс
bash $F/valgrind.sh b-ждут-1000 5000

# 9. Во что упирается при нехватке памяти (двоичный поиск по ulimit -v, КиБ)
bash $F/assemble.sh цепь 10000
bash $F/limit.sh b-цепь-10000 20000 1450000 1460000

# 10. Течёт ли со временем
bash $F/leak.sh b-ждут-10000 100000 400000 1600000 6400000

# 11. Ядра: пул против проверочного режима
for k in 1 2 4 8 16 32 64; do K=$k bash $F/assemble.sh пачка 1024; done
for k in 1 2 4 8 16 32 64; do K=$k TYAZH=32 bash $F/assemble.sh пачка 1024; done
REP=5 bash $F/threshold.sh 4000000 8 1 2 4 8 16 32 64
REP=5 TYAZH=32 bash $F/threshold.sh 400000 8 1 2 4 8 16 32 64

# 12. Где пул не окупается
bash $F/assemble.sh пинг 16
REP=5 bash $F/by-cores.sh b-пинг-16 2000000 1 2 4 8 16

# 13. Где падает печать (двоичный поиск, каждый шаг — настоящая печать)
LC_ALL=C.UTF-8 node $F/stenka.mjs 1000 100000

# 14. Что стоит проверка типов
LC_ALL=C.UTF-8 node $F/tipy.mjs тихо-1000000.flang

# 15. Размеры записей планировщика — у компилятора, а не на глаз
cp $F/sizes.c b-тихо-2/ && cc -O2 -std=c99 -Ib-тихо-2 b-тихо-2/sizes.c -o /tmp/razmery -lm && /tmp/razmery

# 16. Точка отсчёта на настоящей BEAM
erlc -o $Z $F/beam.erl
erl +fnu -noshell -pa $Z +P 20000000 -s beam start -s init stop