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

WebAssembly через C: замер, а не оценка

Вопрос. Нужна ли flang девятая цель печати — в WebAssembly?

Ответ. Не нужна. WebAssembly уже получается через C, и напечатанный C переезжает туда без единой правки. Весь корпус — 94 программы, 8799 точек сетки — отвечает в WebAssembly ровно то же, что и обычная сборка: значения, коды отказов и тексты диагностик совпадают побайтово. Цена — не новый код, а два флага сборки и короткий список того, что не переезжает и не должно.

Замер сделан на ветке work/wasm-cherez-c от 6e186c5.


1. Что стояло на машине

версияоткуда
clang21.1.8 (Ubuntu), цели wasm32, wasm64уже стоял
wasi-libc0.0~git20250726.3f7eb4c-4уже стоял, пакет Ubuntu
libclang-rt-dev-wasm321:21.1.8-6ubuntu1уже стоял
lld (wasm-ld)1:21.1.6-71уже стоял
Nodev26.7.0, встроенный node:wasi (preview1)уже стоял
wasmtime47.0.3не было, поставил я
Emscripten (emcc)нет
wasmerнет

Ничего собирать из исходников не пришлось: цепочка сборки в WebAssembly уже лежала в системе целиком. Заголовки — /usr/include/wasm32-wasi, библиотеки — /usr/lib/wasm32-wasi, и clang находит их сам, без --sysroot.

wasmtime поставлен без прав администратора, тарболлом релиза в ~/.local/bin:

VER=$(curl -s https://api.github.com/repos/bytecodealliance/wasmtime/releases/latest \
      | grep -oP '"tag_name": *"\K[^"]+')
curl -sL -o /tmp/wasmtime.tar.xz \
  "https://github.com/bytecodealliance/wasmtime/releases/download/$VER/wasmtime-$VER-x86_64-linux.tar.xz"
tar xf /tmp/wasmtime.tar.xz -C /tmp
cp /tmp/wasmtime-$VER-x86_64-linux/wasmtime ~/.local/bin/ && chmod +x ~/.local/bin/wasmtime

Emscripten не понадобился и, судя по замеру, не нужен: clang --target=wasm32-wasi делает всё, а Emscripten дал бы вдобавок эмуляцию POSIX, которая нужна ровно одному нашему файлу — человеческой оболочке (§6).


2. Как это собирается

Напечатанный бэкендом C собирается в WebAssembly тем же Makefile, только двумя подменёнными переменными:

node flang/bin/flang.mjs emit программа.flang --target c --out каталог
make -C каталог CC="clang --target=wasm32-wasi" LDLIBS="-lm"

Правок в исходниках — ноль. Предупреждений — ноль, при том что сборка идёт под -std=c99 -Wall -Wextra -Werror -pedantic -O2, то есть любое предупреждение остановило бы её.

Убрать пришлось -lpthread, и это не подгонка под wasm, а честное следствие: в WebAssembly потоков нет, а рантайм это уже знает. Платформенная часть fl_call_deep закрыта проверкой __unix__, которой у wasm32-wasi нет, — значит расчёт идёт на главном стеке, ровно как и задумано для не-POSIX систем. Ни одной строчки «а если wasm» в дереве нет и не потребовалось.

Сборка в WebAssembly оказалась быстрее обычной: 78.5 с против 107.2 с на всём корпусе (в другом прогоне 137.6 против 182.9 — машина занята, но знак разницы держится).


3. Главное: сверка корпуса

Метод. Одна и та же программа печатается в C один раз, потом этот C собирается дваждыcc и clang --target=wasm32-wasi. Через оба бинарника гоняется одна и та же сетка входов, и сравнивается вся строка ответа побайтово, а не значение: расхождение в тексте диагностики — тоже расхождение.

Корпус взят не выдуманный, а тот, которым уже сверяются шесть целей печати (flang/test/corpus-grid.mjs): flang/stdlib/*.flang и flang/examples/leetcode/*.flang, сетка — аргументы примеров плюс их порча заведомо чужими значениями.

Эталон здесь — обычная сборка, а не интерпретатор. С интерпретатором обычную сборку уже сверяет flang/test/emit-c.test.mjs; спрашивали же другое — теряет ли что-нибудь переход в WebAssembly.

средастек модуляпрограммточекрасхожденийупало
node:wasiпо умолчанию (64 КиБ)92 из 94861202
node:wasi1 МиБ94 из 94879900
wasmtime1 МиБ модуль + 8 МиБ среда94 из 94879900

Расхождений нет ни одного ни в одном прогоне. Две программы, упавшие при стеке по умолчанию, — не расхождение семантики, а следствие §7: им не хватило стека, о чём под умолчанием никто не предупреждал.

Повторить:

node scripts/wasm-sverka.mjs --stack 1048576                     # node:wasi
node scripts/wasm-sverka.mjs --stack 1048576 --host wasmtime     # wasmtime
node scripts/wasm-sverka.mjs                                     # умолчание, ловушка §7

4. Процессы и планировщик переехали целиком

Ожидалось, что здесь будет больно: планировщик конкурентности — 1632 строки (flang/src/emit/c/flang_conc.c). Оказалось, что переносить там нечего.

flang_conc.c не включает ничего, кроме math.h, stdint.h, stdio.h и string.h. Ни потоков, ни системных вызовов, ни таймеров: планировщик кооперативный и однопоточный по построению — то самое, что печать сообщает полем «параллелизм»: «нет».

Проверено, а не выведено из чтения: восемь примеров flang/conc/examples (counter, mailbox, supervision, escalate, backpressure, race, budget, measure), 16 объявленных прогонов × 5 семян = 80 сверок журнала доставок, все побайтово. Журналы не пустые: от 406 до 1344 байт.

Это важнее, чем кажется: журнал доставок зависит от всего чередования, и совпадение при одном семени означает, что планировщик принял в WebAssembly ровно те же решения в том же порядке.


5. Сам компилятор flang в WebAssembly

Самая интересная проверка — и она проходит.

bootstrap/kompilyator_flang.c (4.5 МБ, 1588 функций) **собирается в wasm32-wasi без единой правки и без единого предупреждения.** Слинкованный модуль — 4 567 408 байт против 4 249 992 байт обычного бинарника, то есть всего в 1.07 раза толще.

Он работает: лексер, парсер и полная проверка (связывание → типы → завершаемость) на настоящих файлах дают тот же ответ, что и обычная сборка. На 11 файлах × 3 вызова = 33 запроса совпало 33 из 33 — при условии, что вход подан не через трубу под node:wasi (§8) и стека хватает (§7).

Проверено на merge-sort, fibonacci, levenshtein-distance, трёх задачах leetcode, stdlib/strings, stdlib/hashmap, conc/examples/counter, self/lexer.flang (117 272 символа) и self/parser.flang (362 348 символов).

Одна оговорка: чтобы слинковать модуль, пришлось подставить затычку вместо человеческой оболочки — см. §6. Компилятор как таковой (протокол «JSON на входе, JSON на выходе») от этого не теряет ничего.


6. Что НЕ переезжает — поимённо

Ровно один файл: **flang_repl.c**, человеческая оболочка (--help, --version, check <файл>, repl). 8 ошибок компиляции, две причины:

  1. Сигналы. sigaction, SIGINT, sigemptyset, sig_atomic_t — Ctrl-C, который бросает набранное, но не сессию.
  2. **mkdtemp** — временный каталог, куда оболочка печатает и собирает код, чтобы что-нибудь вычислить.

Это не лечится флагами, и проверено, а не предположено:

И это правильно, а не досадно. Оба вызова нужны оболочке ровно затем, чтобы спрашивать у мира: где cc, где каталоги установки, что нажал человек. В WebAssembly мира нет по определению — в этом весь смысл. Файл flang_cli.c, который обещает быть переносимым C99 и ничего у мира не спрашивать, обещание держит: он собрался без замечаний.

Практически это значит: в WebAssembly едет компилятор, а не оболочка. Для песочницы и браузера — ровно то, что нужно.


7. Ловушка: глубина вызовов

Это единственный настоящий дефект, найденный замером, и он опасен тем, что молчит.

Что происходит. Рантайм без POSIX считает, что стека у него 1 МиБ (FL_STACK_ROOM_FALLBACK в flang_runtime.h) — это разумное занижение для неизвестной платформы. А wasm-ld по умолчанию отводит теневой стек 64 КиБ. Числа расходятся в 16 раз, и проверка глубины пропускает вызов, для которого места нет.

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

{"ok":false,"code":"FLANG_TYPE","message":"«соединить» допустимо только для строк, получено строка и ничто"}

на входе, который обычная сборка разбирает без единого замечания. Обещание «завершится ИЛИ откажет честно» держится ровно до тех пор, пока число в сборке не врёт про настоящий стек.

Лечится двумя флагами, и оба обязательны:

clang --target=wasm32-wasi -DFL_STACK_ROOM_FALLBACK=8388608u ... \
      -Wl,-z,stack-size=8388608

Одного флага линковки мало: он даёт память, но не сообщает о ней рантайму, и проверка продолжает считать по старому числу. Измерено: с -Wl,-z,stack-size от 1 до 256 МиБ без -D компилятор одинаково отказывал «исчерпала стек хозяина на глубине 71» — потому что 71 кадр это и есть 1 МиБ по его расчёту. С обоими флагами по 8 МиБ тот же вход считается правильно.

Почему 8 МиБ: это FL_STACK_MIN рантайма — столько же обычная сборка заводит потоку под объявленный предел глубины. Не круглое число, а то же самое.

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

средапредел по умолчаниюкак поднять
wasmtime512 КиБ-W max-wasm-stack=8388608
node:wasiзаметно большеотдельной ручки нет

wasmtime при этом ведёт себя честнее: он даёт wasm trap: call stack exhausted со стеком вызовов, а не портит память. С поднятым пределом совпадение восстанавливается целиком.

Итог по глубине: рекомендуемая сборка — 8 МиБ с обеих сторон, и тогда пределы у WebAssembly и обычной сборки совпадают.


8. Ловушка: стандартный ввод под node:wasi

Отдельная стена, и она не наша.

Под node:wasi запрос крупнее примерно 128 КиБ, поданный через трубу, обрывается, и прогонщик отвечает {"ok":false,"code":"CLI","message":"неразборчивые аргументы"}. Обычная сборка на том же входе работает.

Проверено тремя способами, и все три показывают на хозяина:

  1. Тот же вход из файла вместо трубы — совпадение с обычной сборкой на всех размерах вплоть до 885 КБ.
  2. Правка в C не помогает. Очевидная догадка — «read_all принимает короткое чтение за конец ввода» — проверена сборкой: +3 строки в flang/src/emit/c/flang_cli.c (ждать конца у feof/ferror, а не у длины), собрано под -Werror, результат не изменился. node:wasi выставляет настоящий признак конца ввода, и отличить его от «данных пока нет» программе на C нечем. В дерево правка не внесена — она ничего не чинит.
  3. wasmtime не теряет ничего. 1000, 4000, 8000, 12 000, 16 000, 32 000, 64 000 элементов — ответ знак в знак с обычной сборкой, включая коды отказов.

Есть и зеркальная беда, и она была в моём собственном прогоннике: node:wasi теряет хвост и на выводе в трубу. Восемь прогонов одного и того же модуля дали 171 819 байт пять раз и 158 167, 163 072, 166 220 остальные три, тогда как обычный бинарник давал 171 819 все восемь раз. Не поймай я это — в отчёте стояло бы «wasm искажает вывод», и это была бы ложь с числами. Починено в scripts/wasm-run.mjs (код возврата полем, а не process.exit()) и в сверке (вывод забирается через файл).

**Вывод: для настоящей работы берите wasmtime, а не node:wasi.** node:wasi годится для мелких вызовов и удобен тем, что есть везде, где есть Node.


9. Пределы памяти

Потолок адресного пространства wasm32 измерен: 4080.1 МиБ (65 282 страницы по 64 КиБ). -Wl,--max-memory его не меняет — это и есть весь wasm32 без малого. Для сравнения, обычная сборка под ulimit -v 4194304 даёт ровно столько же; без ограничения — сколько есть на машине.

Первая проба намеряла «16 ГиБ» и была враньём: счётчик был size_t, а он на wasm32 32-битный, и 6.4 ГиБ завернулись через 2³². Хуже того: **malloc в wasi-libc после исчерпания адресного пространства продолжает выдавать указатели, и они перекрываются** — то есть проверять надо не «вернул ли malloc не-NULL», а метку на каждом куске. Исправленная проба ловит это и честно говорит malloc вернул NULL после 255 кусков.

На каком входе программы перестают помещаться. Мерено на сортировке вставками (flang/stdlib/lists.flang, «Сортировать») на худшем входе — список, убывающий от N до 1:

Nобычная, МиБ RSSобычнаяwasm, МиБ линейной памятиwasmсовпало
100026значение19значениеда
2000130значение73значениеда
4000536значение452значениеда
60001200значение1184значениеда
80002114значение2239значениеда
120004726значениеотказнет
160008350значениеотказнет
2400018706значениеотказнет

Стена — между 8000 и 12 000 элементов, и ставит её потолок wasm32, а не WebAssembly как таковой: на 8000 элементах модуль уже занимает 2239 МиБ из доступных 4080. Обычная сборка на 24 000 берёт 18.7 ГиБ и считает.

То есть на этой программе wasm упирается там, где обычной сборке нужно больше 4 ГиБ — примерно вдвое раньше по числу элементов, потому что расход растёт быстрее входа.

Расход такой не из-за WebAssembly, а из-за того, что область памяти не отдаёт ничего до конца вызова. Рядом идёт работа, которая чинит именно это (область памяти на вызов; прототип дал в 486 раз меньше). Если она приедет, потолок сдвинётся ровно во столько же раз, и стена уедет далеко за нынешние 8000 — без единой правки в пути через C.


10. Цена: размер, запуск, скорость

Меряется на одном напечатанном C, собранном дважды. Медиана из 7 прогонов, в скобках мин—макс: машина занята другими работами.

Размер. Модуль .wasm крупнее обычного бинарника в 2.26—2.71 раза на отдельных программах и в 2.637 раза на всём корпусе (21 182 845 против 8 032 600 байт). Исключение — сам компилятор: там 1.07 раза, потому что у большой программы своя доля кода перевешивает служебную.

Запуск (пустой вход, работы ноль):

мс
обычная сборка3.7 (3.4—4.6)
wasm под wasmtime56.6 (52.6—65.1)

Дороже в 15.4 раза, разница 53 мс. Это цена среды: она обязана разобрать и оттранслировать модуль при каждом запуске.

Счёт (то же время за вычетом цены запуска):

задачаобычная, мсwasm, мсwasm медленнее
сортировка вставками, 2000147.3103.30.70×
сортировка слиянием, 2000311.2277.30.89×

WebAssembly считает не медленнее обычной сборки — на этих двух задачах даже быстрее. На мелких задачах это число не читается: там весь разрыв даёт запуск, и вычитание 53 мс из 56 мс даёт шум, а не скорость.

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


11. Так надо ли писать девятую цель печати

Нет. Ни одной строки в flang/src/emit/c.mjs для WebAssembly писать не требуется: напечатанный C уже собирается туда как есть и отвечает знак в знак на 8799 точках корпуса.

Единственная правка, которую можно сделать и которая ничего не меняет по существу, — удобство: цель wasm в порождаемом Makefile, чтобы не подставлять переменные руками. Её цена измерена, а не оценена: правка в flang/src/emit/c.mjs+17 / −2 строки, из них 5 строк комментарий. Проверено сборкой: правленый Makefile действительно собирает модуль по make wasm. В дерево не внесена — это решение владельца.

Для сравнения: отдельная цель печати в WebAssembly — это ещё один эмиттер масштаба существующих, со своей моделью памяти, своим представлением значений, своим прогонщиком и своей сверкой на 8799 точках. Ближайший ориентир из дерева — flang/src/emit/*.mjs, и это тысячи строк плюс постоянная стоимость: девятая цель означает девятое место, где расходится семантика.

Что при этом получено даром сверх ожидаемого:

Что надо знать тому, кто этим воспользуется, — три пункта, и все три названы числами выше:

  1. собирать с -DFL_STACK_ROOM_FALLBACK=8388608u -Wl,-z,stack-size=8388608 (иначе §7 — молчаливая порча памяти, а не честный отказ);
  2. брать wasmtime и поднимать ему -W max-wasm-stack=8388608 (иначе §8 — обрыв ввода, и §7 — ранний обрыв рекурсии);
  3. помнить про потолок 4080 МиБ (§9).

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

# 1. Что стоит в системе
clang --version && node --version && ~/.local/bin/wasmtime --version
dpkg -l wasi-libc lld libclang-rt-21-dev-wasm32 | tail -3

# 2. Одна программа руками
node flang/bin/flang.mjs emit flang/examples/rosetta/merge-sort.flang --target c --out /tmp/ms
make -C /tmp/ms CC="clang --target=wasm32-wasi" LDLIBS="-lm"
echo '{"fn":"Сортировка слиянием","args":[{"l":[{"n":"3"},{"n":"1"},{"n":"2"}]}]}' \
  | ~/.local/bin/wasmtime run -W max-wasm-stack=8388608 /tmp/ms/flang_cli

# 3. Сверка всего корпуса (94 программы, 8799 точек)
node scripts/wasm-sverka.mjs --stack 1048576 --host wasmtime
node scripts/wasm-sverka.mjs --stack 1048576            # то же под node:wasi
node scripts/wasm-sverka.mjs                            # умолчание: видно ловушку §7

# 4. Потолок памяти: где программа перестаёт помещаться
node scripts/wasm-potolok.mjs --n 1000,2000,4000,8000,12000

# 5. Размер, запуск, скорость
node scripts/wasm-skorost.mjs --repeats 7

# 6. Точка раскрутки: компилятор flang в WebAssembly
cp -r bootstrap /tmp/boot-wasm
make -C /tmp/boot-wasm CC="clang --target=wasm32-wasi" LDLIBS="-lm" -j4
#   flang_repl.c упадёт с 8 ошибками — это §6, так и должно быть;
#   kompilyator_flang.o, flang_runtime.o и flang_cli.o соберутся.

Оснастка замера в дереве:

файлчто делает
scripts/wasm-run.mjsзапускает модуль поверх node:wasi; FLANG_WASM_PAMYAT=1 печатает размер линейной памяти
scripts/wasm-sverka.mjsкорпус через два бинарника, побайтовая сверка ответов
scripts/wasm-potolok.mjsна каком размере входа программа перестаёт помещаться
scripts/wasm-skorost.mjsразмер модуля, цена запуска, скорость счёта

Ни один из них не трогает flang/src/emit/ — цель печати не написана и не нужна.