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

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

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

Дата замера: 15 августа 2026. Всё, что здесь названо числом, — прогон, а не оценка по коду. Команды для повтора приложены к каждому разделу.

Читать вместе с дополнением от 16 августа в конце файла. Пункты 1, 2 и 6 списка «что менять» сделаны на следующий день, и короткий ответ владельцу изменился: не «десятки тысяч», а миллион работающих процессов, заведённых прогоном за 11,6 секунды, и доставка при миллионе стоит те же полмикросекунды, что при двух. Всё, что стоит ниже, — измеренное состояние на 15 августа, и оно оставлено как есть: это точка отсчёта, а не текущее описание.


Что вообще есть в дереве

Прежде чем мерить, стоит сказать, что именно меряется, — описание задачи и то, что лежит в дереве, разошлись в одном важном месте.

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

ЦельФайлЧто это
Cflang/src/emit/c/flang_conc.c (3089 строк)свой планировщик; рабочий режим — потоки берут процессы, у каждого поток свой склад готовых
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.

И главное, чего в описании задачи не было:

Процессы в flang не порождаются на ходу. Их набор задан объявлениями в исходнике и печатается таблицей данных. Слова породить в языке нет вовсе — это записано и в flang/conc/RESILIENCE.md, и в границе честности SPEC.md.

ЭТО ПЕРЕСТАЛО БЫТЬ ПРАВДОЙ НА СЛЕДУЮЩИЙ ДЕНЬ. Слово породить есть, процессы заводятся на ходу в модели (flang/src/conc.mjs:1077) и в цели C (flang/src/emit/c/flang_conc.c:1390); нет его у целей JavaScript и Elixir. Абзац оставлен как условие, при котором снимались числа 15 августа, — см. раздел 3 дополнения от 16 августа и стенд «рой».

Отсюда следовала вещь, которую надо было понять до всех чисел: чтобы процессов стало миллион, миллион объявлений надо написать в исходнике и провести через разбор, проверку типов, печать в C и компилятор C. «Сколько процессов тянет планировщик» и «сколько процессов можно завести» — в этом языке были два разных вопроса, и ответы у них разные. С порождением второй вопрос отвечается прогоном: исходник стенда «рой» постоянного размера — 2 628 байт и два объявления, — а миллион процессов он заводит за 11,6 секунды.

Планировщик, кроме того, однопоточный по замыслу, и замысел записан в самом файле (flang/src/emit/c/flang_conc.h, раздел «Почему один поток, а не пул»): единственный источник случайности — выбор процесса из очереди готовых, и он берётся из генератора, заведённого семенем. Так напечатанный C даёт побайтово тот же журнал доставок, что эталон, и это сверяется тестами. Пул потоков семени не имеет, и такую сверку сделать было бы нечем.


Условия замера, чтобы числа были числами

Машина: 256 процессоров, 499 ГБ памяти, gcc 15.2.0, node v26.7.0, Erlang/OTP 29 (erts-17.0.5, JIT), Elixir на нём же.

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

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

Стенды для замера лежат в flang/conc/zamer/. В командах ниже $F — путь к этому каталогу, а работать надо в пустом рабочем каталоге (см. «Как повторить»).


1. Сколько стоит один молчащий процесс: 399 байт

Стенд «тихо»: 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/razmery.c): слот процесса 168 байт, плюс место в очереди готовых, признаках, дереве надзора и итоговых состояниях — 234 байта в памяти прогона; плюс 129 байт в самом двоичнике (строка имени и запись в таблице процессов).

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

Молчащий процесс flang в 6,6 раза дешевле процесса Erlang.

bash $F/sobrat.sh тихо 1000000
bash $F/pamyat.sh b-тихо-1000000

2. Сколько стоит процесс, который поработал: 128 КиБ

Это главное число отчёта, и оно в 320 раз больше предыдущего.

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

Точные байты, выданные malloc (valgrind, числа повторяются до байта):

СтендВыделенийБайт всего
2 процесса работают10467 408
1 000 процессов работают2 009131 724 304
10 000 процессов работают19 9581 311 286 800

Наклон: (1 311 286 800 − 131 724 304) / 9 000 = 131 062 байта на процесс. Это ровно 128 КиБ — два куска по 64 КиБ, по одному на половину кучи. Выделений ровно два на процесс, и это видно в счётчике.

Сколько адресного пространства нужно на самом деле — проверено двоичным поиском по ulimit -v:

Работающих процессовНе хватаетХватаетНа процесс
10 0001 450 000 КиБ1 460 000 КиБ~146 КиБ
100 00010 000 000 КиБ14 000 000 КиБ~140 КиБ

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

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

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

Оперативной памяти при этом тратится куда меньше, чем адресного пространства: из куска в 64 КиБ трогается одна-две страницы. Замер: 300 000 разбуженных процессов дали пик RSS 2,84 ГиБ, то есть 9,7 КиБ живой памяти на работающий процесс против 146 КиБ зарезервированных.

Миллион работающих процессов — это 146 ГБ адресного пространства и около 10 ГБ живой памяти. У Erlang тот же миллион — 2,7 ГБ, и всё живое.

bash $F/sobrat.sh ждут 1000 && bash $F/valgrind.sh b-ждут-1000 5000
bash $F/sobrat.sh цепь 10000 && bash $F/predel.sh b-цепь-10000 20000 1450000 1460000

3. Сколько стоит завести процесс

Порождения на ходу нет, поэтому у вопроса два ответа.

Заведение при старте прогона. Планировщик строит все N слотов и вызывает начальную функцию каждого процесса. Настенное время всего запуска:

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

Наклон: 1,04 мкс на процесс → около 960 000 процессов в секунду.

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

То есть заводить процессы flang вдвое быстрее, чем BEAM. Но заводятся они один раз, при старте программы, и после этого множество процессов не меняется.

Заведение через компилятор — единственный способ добавить процесс. На миллион процессов:

ШагВремя
исходник (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 с — всё растёт линейно, так что до десятков тысяч процессов эта стенка не мешает.


4. Сколько стоит передать сообщение: от 0,4 мкс до 4,8 мс

Пинг-понг между двумя процессами, наклон между 1 000 000 и 16 000 000 пробегов, минимум из семи повторов:

0,400 мкс на доставку → 2,50 миллиона сообщений в секунду. (Разброс по семи повторам: 0,400–0,415 мкс.)

Erlang на этой же машине: миллион кругов пинг-понга, минимум 1 311 185 мкс → 762 669 кругов в секунду → 1,53 миллиона сообщений в секунду.

На двух процессах планировщик flang в C быстрее BEAM в 1,6 раза. Это честное число, и оно приятное.

Оно перестаёт быть верным ровно тогда, когда процессов становится много.


5. Главная находка: доставка стоит перебора всей таблицы процессов

Адрес в отправить — это строка с именем процесса. Чтобы её доставить, планировщик ищет имя в таблице процессов перебором сверху, сравнивая строки (fl_conc_address, flang/src/emit/c/flang_conc.c:506). Никакого указателя, никакой хеш-таблицы: цикл for по всем объявленным процессам.

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

Собеседники объявлены ПЕРВЫМИ (имя находится сразу). Три строки сняты подряд, одним заходом, при одной и той же нагрузке — сравнивать их между собой можно, с числами из других разделов складывать нельзя:

Объявлено процессовмкс на доставку
20,566
1 0000,546
100 0000,544

Ровная линия. Число объявленных процессов само по себе не стоит ничего.

(Тот же стенд из двух процессов, перемеренный позже, когда нагрузка на машине упала со стапятидесяти до сорока, дал 0,400 мкс. Это разница между двумя состояниями машины, а не между двумя стендами; ровность линии выше от неё не зависит, потому что все три её точки сняты одновременно.)

Собеседники объявлены ПОСЛЕДНИМИ (имя ищется до конца таблицы). Эти четыре строки сняты своим заходом, и точка отсчёта у них своя — те же 0,400 мкс на двух процессах:

Объявлено процессовмкс на доставкуво сколько раз дороже
20,400
1 0004,3811×
10 00034,486×
100 000307768×
1 000 0004 80812 000×

Наклон: 3,34 нс на объявленный процесс между 1 000 и 10 000; 3,03 нс между 10 000 и 100 000; около 5 нс дальше — на миллионе таблица в 48 МБ перестаёт помещаться в кэш процессора, и каждое сравнение стоит промаха.

При миллионе объявленных процессов одна доставка занимает 4,8 миллисекунды. Это 208 сообщений в секунду против полутора миллионов у Erlang — разница в семь тысяч раз.

Это и есть ответ на вопрос «возможны ли миллионы в принципе». В нынешнем виде — нет, и не из-за планировщика как такового, а из-за одного цикла for в поиске адресата.

bash $F/sobrat.sh пингконец 100000
bash $F/naklon.sh b-пингконец-100000 20000 120000 5 0

6. Вторая находка: переключение стоит числа ГОТОВЫХ процессов

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

Сам выбор постоянен: планировщик берёт случайный номер в массиве готовых, это одно действие. Но очередь готовых хранится отсортированным массивом, и каждое добавление и удаление — это memmove половины массива (fl_conc_refresh, flang/src/emit/c/flang_conc.c:561). На каждый пробег таких движений два.

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

Готовых процессовмкс на переключение
20,285
1 0000,46
10 0001,66
31 0004,83

Наклон между соседними строками: 0,18 / 0,133 / 0,151 наносекунды на готовый процесс. То есть каждый готовый процесс добавляет около 0,15 нс к каждому переключению, и добавляет линейно.

Дальше 31 000 замерить не удалось, и причина сама по себе показательна: разбудить процесс можно только сообщением, а единственная дверь для сообщений снаружи — строки дано в прогоне, а их печатается около 31 350 (см. ниже). Будить изнутри — это платить 4,8 мс за каждое пробуждение на миллионе, то есть около восьмидесяти минут только на то, чтобы дойти до нужного состояния.

Пересчёт по измеренному наклону (это оценка, а не замер): миллион готовых процессов — около 150 мкс на переключение, то есть примерно 6 700 переключений в секунду.


7. Где потолок: пять стенок, и все названы точно

**Стенка 1 — печать сообщений прогона. Около 31 350 сообщений в одном дано.**

Найдено двоичным поиском, каждый шаг — настоящая печать. Точное сообщение:

RangeError: Maximum call stack size exceeded
    at flang/src/emit/c.mjs:1754

Строка 1754 — это lines.push(...body). Массив из четырёх строк на каждое сообщение раскладывается в аргументы вызова, и на тридцати одной тысяче сообщений кончается стек JavaScript.

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

Число зависит и от стека node (здесь v26.7.0 с умолчаниями), и от формы сообщения. Но стенка есть при любых настройках — это спред, а не рекурсия, и двигать её настройкой стека значит откладывать, а не чинить.

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

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

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

Стенка 4 — память. 146 КиБ на работающий процесс (раздел 2). Оба возможных сообщения об отказе приведены там же.

Стенка 5 — цепочка компиляции. Миллион объявлений: 166 МБ исходника, 63 секунды на весь путь, двоичник 129 МБ. Ничего не падает, но руками такой исходник никто не напишет — а другого способа завести процесс нет.

Чего НЕ произошло: миллион молчащих процессов поднялся без единой жалобы — 1,05 секунды на старт, 450 МБ памяти, исход "покой". Выше миллиона не поднимались: смысла нет, стенки уже названы.


8. Ядра планировщик не занимает — и это проверено, а не прочитано

Во всех замерах этого отчёта /usr/bin/time показывал 96–100% процессора при настенном времени, равном процессорному. Одно ядро, целиком.

Прямая проверка: одна копия пинг-понга на 4 000 000 пробегов идёт 1,69–1,75 с. Восемь копий, запущенных одновременно, заканчиваются все вместе за 1,72–1,81 с. Восьмикратная работа за то же время — значит ядра были свободны, и планировщик их не берёт.

Цель Elixir ядра берёт: тот же пинг-понг там идёт при 222–242% процессора, потому что его исполняет BEAM.

bash $F/yadra.sh b-пинг-0 4000000 8

9. Другие цели

Elixir — единственная цель, где процесс flang становится настоящим процессом операционной системы языка: use GenServer, дерево Supervisor. Значит там работают все свойства BEAM — вытеснение, настоящая параллельность, миллион процессов по 2,7 КБ.

Но платится за это скоростью. Тот же пинг-понг:

ЦельСообщений в секундуЯдер
C2 500 0001
Elixir~16 0002,3

Разница в 150 раз. У напечатанного Elixir каждый пробег ведёт учёт в общей таблице ETS, а прогонщик опрашивает состояние покоя раз в две миллисекунды.

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

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


Итог: сколько тянем сегодня

ЧтоСколькоЧем ограничено
молчащих процессов1 000 000 провереноцепочкой компиляции, не рантаймом
работающих процессов10 000 – 30 000146 КиБ памяти на процесс
процессов, которым можно послать письмо снаружи~31 350падение печати в C
сообщений в секунду на двух процессах2 500 000лучше, чем BEAM
сообщений в секунду на миллионе процессов208перебор таблицы имён
ядер1замысел (нужен для сверки по семени)

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


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

По возрастанию цены. Первые два дают почти весь выигрыш.

1. Поиск адресата: хеш вместо перебора (~120–190 строк)

fl_conc_address и fl_conc_find в flang/src/emit/c/flang_conc.c (строки 476–520) перебирают таблицу процессов на каждое письмо. Таблица известна на этапе компиляции и не меняется — значит вариантов два, и оба дешёвые:

Убирает разницу между 0,4 мкс и 4,8 мс. Наблюдаемое поведение не меняется ничем — тот же адресат, та же очередь, тот же журнал, — значит сверка с эталоном остаётся зелёной.

2. Кусок кучи на процесс: 64 КиБ → сотни байт (~40–70 строк)

FL_CHUNK_MIN в flang/src/emit/c/flang_runtime.c:55 — одна константа на все арены сразу. Для главной арены 64 КиБ правильно. Для кучи процесса — в триста раз много: типичный процесс держит запись из двух полей.

Нужен отдельный размер первого куска для куч процессов (поле в fl_arena или второй fl_arena_init_small): ~30–60 строк в flang_runtime.c/.h плюс десяток в flang_conc.c.

Это переводит миллион работающих процессов из 146 ГБ в 1–2 ГБ. Наблюдаемое поведение опять не меняется — арена и так растёт по требованию.

3. Очередь готовых: список вместо отсортированного массива (~150–250 строк, плюс сверка)

fl_conc_refresh и fl_conc_lower (flang_conc.c, строки 536–585). Здесь дороже, и вот почему: порядок в очереди готовых — часть наблюдаемого поведения. Семя выбирает номер в этом массиве, поэтому любая структура, дающая другой порядок, даёт другой журнал доставок — и роняет побайтовую сверку с эталоном (flang/test/emit-c-conc.test.mjs, больше двух тысяч сравнений).

Значит либо структура за O(1), сохраняющая тот же порядок перечисления (битовая карта с рангом даст O(P/64) — на миллионе это 16 тысяч слов, всё ещё много), либо сознательная смена основания: поменять порядок и в эталоне flang/src/conc.mjs, и заново прогнать всю дифференциальную сверку, включая сверку с BEAM.

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

4. Печать: убрать спред (~10 строк)

flang/src/emit/c.mjs:1754lines.push(...body) и соседний ...inbox.map(...). Заменить на цикл. Снимает стенку в ~31 350 сообщений полностью.

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

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

6. породить — без него «миллионы» остаются вопросом к компилятору

Пока процессы объявляются, число процессов упирается в размер исходника, а не в рантайм. Динамическое дерево процессов в модели совместимо с тотальностью — в flang/conc/RESILIENCE.md это разобрано и стоит шагами Б1–Б2 карты: породить процесс «Счётчик» с начальным X порождает экземпляр объявленного вида, а не произвольный код, поэтому множество видов остаётся конечным и известным при компиляции.

Честной оценки в строках у меня нет — это не правка, а работа по карте: поверхность языка, проверка типов, эталон, три планировщика.

7. Многоядерность — отдельный режим, а не замена

Однопоточность здесь не недоделка: она нужна, чтобы сверять напечатанное с эталоном побайтово по семени. Любая параллельность обязана быть вторым режимом, включаемым явно. В flang/conc/SPEC.md (шаг 6) записан замер: отдать пробег другому потоку в четыре–четырнадцать раз дороже, чем выполнить его на месте, — то есть выигрыш появится только на тяжёлых обработчиках.


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

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

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

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

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

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

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

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

# 5. Цена переключения против числа ГОТОВЫХ процессов
bash $F/sobrat.sh ждут 2 1000 10000
bash $F/naklon.sh b-ждут-10000 100000 600000 5 0

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

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

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

# 9. Занимает ли ядра
bash $F/yadra.sh b-пинг-0 4000000 8

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

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

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

# 13. Порождение: сколько процессов заводит ПРОГОН (дополнение 16 августа)
bash $F/sobrat.sh рой 1
bash $F/skolko.sh b-рой-1 2000 20000 200000 2000000
REP=5 bash $F/roy.sh b-рой-1 20000 40000 60000 80000 160000

# 14. Цена доставки одной строкой на стенд, без ручной арифметики
bash $F/dostavka.sh 20000 120000 b-пингконец-1000 b-пингконец-10000 b-пингконец-100000

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

Чего этот замер не даёт


Дополнение от 16 августа: порождение и две снятые стенки

Отчёт выше кончался списком «что менять». Пункты 1, 2 и 6 этого списка сделаны в тот же день — ветка work/porodit, — и здесь их числа, снятые ТЕМИ ЖЕ стендами. Сравнивать с таблицами выше их можно только по форме, но не по уровню: машина под нагрузкой, и «до» ниже перемерено заново, а не переписано из вчерашних таблиц. Где взято вчерашнее число, это сказано.

Стенды собраны из ДВУХ деревьев — origin/main и ветки — и мерены подряд, при одной и той же нагрузке.

Улика ДО, на одном и том же файле. Пример flang/conc/examples/server.flang на дереве origin/main:

FLANG_UNKNOWN_NAME | неизвестный конструктор варианта «породить» | строка 123

На ветке тот же файл проходит проверку и все свои прогоны — семь проверок из семи, — а напечатанный из него C заводит обработчиков на ходу и переживает их смерть:

исход: покой | пробегов: 8
процессы: Сервер, Обработчик, Журнал, обработчик-1, обработчик-2, обработчик-3
живые:    Сервер, Обработчик, Журнал
отказы:   обработчик-2 / FLANG_STOPPED
решения:  обработчик-2 / Присмотр / остановить

Трёх последних процессов в исходнике нет: их завёл прогон.

1. Доставка перестала перебирать таблицу имён

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

Объявлено процессовДО, мкс на доставкуПОСЛЕ, мкс на доставкуво сколько раз
2 (стенд «пинг»)0,5000,477
1 0005,100,6008,5×
10 00044,70,60074×
100 0004710,600786×
1 000 0004 808 (вчера)0,492~9 800×

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

2,03 миллиона сообщений в секунду при миллионе процессов против 208 вчера и 1,53 миллиона у Erlang/OTP 29 на этой же машине.

Сделано указателем имён (открытая адресация, FNV-1a, номер uint32_t), который планировщик строит один раз при старте прогона и дополняет на каждом порождении. Второй вариант из отчёта — «печать подставляет номер вместо строки» — порождением ЗАКРЫТ: таблица процессов теперь растёт в рантайме, и номера, известного при печати, у порождённого нет.

Цена указателя измерена там же, где он стоит, — на молчащих процессах (стенд «тихо», 100 000 процессов, пять повторов):

ДОПОСЛЕ
мелких промахов страниц9 848–9 85010 689–10 692
пиковый RSS43 020 КиБ47 140 КиБ

Прирост — 41 байт на молчащий процесс (399 → около 440). Из них восемь-десять байт указатель, шестнадцать — поле «каким куском покупать» в арене (см. пункт 2), остальное округление страниц. Разменяны 41 байт на 786 раз, и размен назван, чтобы его можно было оспорить.

bash $F/dostavka.sh 20000 120000 b-пингконец-1000 b-пингконец-10000 b-пингконец-100000

2. Куча процесса покупается полукилобайтом, а не 64 КиБ

FL_CHUNK_MIN была одной константой на все арены сразу. Теперь размер первого куска — поле арены (fl_arena_init_small), и он удваивается до общего минимума; кучи процессов заводятся с 512 байтами, черновик пробега и почта остаются на 64 КиБ.

Стенд «ждут», 10 000 процессов, готовы всегда все, 100 000 пробегов, три повтора:

ДОПОСЛЕво сколько раз
пиковый RSS88 064 КиБ14 336–16 384 КиБ5,4×
мелких промахов страниц22 606–22 6084 399–4 4015,1×
на работающий процесс9,0 КиБ1,5 КиБ
процессорное время0,76–0,90 с0,58–0,88 сбез изменений

Точный прибор (счётчик malloc из valgrind, числа повторяются до байта, сняты проверками flang/test/emit-c-conc.test.mjs):

СтендДОПОСЛЕ
два работающих процесса467 408 Б207 312 Б
прогон надзора на 20 семенах6 696 368 Б1 494 448 Б

Адресное пространство — двоичным поиском по ulimit -v, стенд «рой», 100 000 процессов:

Кусок кучиНе хватаетХватаетНа процесс
64 КиБ (как было)13 000 000 КиБ14 000 000 КиБ~140 КиБ
512 Б (как стало)260 000 КиБ280 000 КиБ2,87 КиБ

То же вторым прибором, по пиковому RSS на ста тысячах порождённых: двойник с куском 64 КиБ — 892 928 КиБ (9 144 байта на процесс), нынешний стенд — 178 176 КиБ (1 825 байт). Оба стенда собраны из одних и тех же напечатанных исходников и отличаются одной строкой.

bash $F/dvoynik.sh кусок b-рой-1 b-ройкусок-1
bash $F/skolko.sh b-ройкусок-1 200000
bash $F/skolko.sh b-рой-1      200000

Пятьдесят раз. Миллион работающих процессов — это больше не 146 ГБ адресного пространства, а около трёх.

Побочное, но приятное: отчёт выше жаловался, что при нехватке памяти наружу выходит «недостаточно памяти для текста диагностики» вместо настоящего сообщения. На стенде «рой» под пределом выходит ровно то, что планировщик собирался сказать: {"ok":false,"code":"FLANG_MEMORY","message":"кончилась память в планировщике конкурентности"}. Причина та же — куче процесса больше не нужно 128 КиБ, поэтому к моменту отказа память на строку ещё есть.

3. породить: процессы заводит прогон, а не компилятор

Новый стенд — рой (flang/conc/zamer/gen.mjs). Один объявленный сервер порождает работников на ходу; исходник ПОСТОЯННОГО размера — 2 628 байт и два объявления, — а сколько процессов заведётся, решает предел пробегов запроса. Для сравнения: миллион процессов объявлениями — это 166 МБ исходника, 44 с разбора и 40 с компилятора C.

ПробеговЗаведено процессовПроцессорное времяПиковый 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

Миллион процессов, заведённых прогоном, — 11,6 секунды и 1,7 ГиБ. 1 764 байта на работающий процесс против 2 648 у Erlang/OTP 29.

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

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

bash $F/sobrat.sh рой 1
bash $F/skolko.sh b-рой-1 2000 20000 200000 2000000

4. Довод, который надо было проверить: перебор при порождении делает ХУЖЕ

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

ПробеговЗаведеноУказатель, сПеребор, сво сколько раз
20 00010 0000,070,44
40 00020 0000,181,50
60 00030 0000,283,4712×
80 00040 0000,455,8913×
160 00080 0000,8230,5137×

Разности между соседними точками у указателя — 0,11 / 0,10 / 0,17 / 0,37 с; у перебора — 1,06 / 1,97 / 2,42 / 24,6 с. Первый ряд почти ровный, второй растёт: у перебора цена ОДНОГО пробега пропорциональна числу уже заведённых, поэтому прогон квадратичен по числу процессов.

Перемерено позже, когда нагрузка на машине упала: перебор на 160 000 пробегах дал 19,06 с против 30,51 с, указатель — те же 0,82 с. Отношение при этом осталось того же порядка (23× против 37×), потому что нагрузка удлиняет оба прогона, а не один.

bash $F/dvoynik.sh перебор b-рой-1 b-ройперебор-1
REP=5 bash $F/roy.sh b-рой-1        20000 40000 60000 80000 160000
REP=5 bash $F/roy.sh b-ройперебор-1 20000 40000 60000 80000 160000

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

5. Чего это НЕ сняло

Третья стенка на месте, и теперь она ИЗМЕРЕНА, а не оценена. Очередь готовых — по-прежнему отсортированный массив с memmove на каждое изменение, и порядок в ней часть наблюдаемого поведения (семя выбирает номер В ЭТОМ массиве).

Вчерашний отчёт эту стенку только оценил наклоном и честно назвал оценкой: дальше 31 000 готовых процессов было не пройти, потому что разбудить процесс можно только сообщением, а единственной дверью снаружи были строки дано, и их печаталось около 31 350. Порождение эту дверь открыло. Стенд ройждут: работник откладывает своё сообщение (значит готов всегда) и на первом пробеге заводит двоих таких же — двоичное дерево, у которого число готовых растёт линейно по пробегам.

ПробеговГотовых, примерноПроцессорное, смкс на переключениенс на готовый процесс
20 00015 0000,13
40 00030 0000,3410,50,35
80 00060 0000,9515,30,25
160 000120 0003,0826,60,22
320 000240 00010,5646,80,20
640 000480 00038,7588,10,18

Наклон между двумя последними строками — 0,172 нс на готовый процесс, между предыдущими — 0,168. Вчерашняя оценка была 0,15 нс, снятая на отрезке до 31 000; сегодняшнее измерение доходит до 480 000 и её подтверждает.

Миллион ОДНОВРЕМЕННО ГОТОВЫХ процессов — около 170 мкс на переключение, то есть примерно 5 900 переключений в секунду. Это и есть то, что осталось до миллиона: миллион молчащих или коротко живущих процессов планировщик держит (8,65 с и 1,7 ГиБ), миллион одновременно готовых — нет.

Трогать очередь значит либо строить структуру за O(1) с тем же порядком перечисления, либо сознательно менять основание сверки с эталоном. Это по-прежнему единственный пункт, который стоит обсуждения, а не работы.

bash $F/sobrat.sh ройждут 1
REP=3 bash $F/roy.sh b-ройждут-1 20000 40000 80000 160000 320000 640000

Стенки печати (4 и 5 списка) не трогались. Около 31 350 сообщений в одном дано и двадцатиминутная сборка на них — там же, где были. Порождение делает их менее важными: сообщения теперь можно раздавать порождённым изнутри прогона, а не перечислять в дано.

Порождение есть только у эталона и у цели C. Планировщики JavaScript и Elixir отвечают на неизвестное действие названной ошибкой, а не молча делают не то; печать программы с породить в эти цели не запрещена, и это записанный долг, а не недосмотр.

Ядер по-прежнему одно. Порождение к этому отношения не имеет: одноядерность нужна для сверки по семени, и порождение её не тронуло — то же семя даёт тот же журнал, и это проверяется побайтово на программе, у которой таблица процессов растёт за прогон.