WebAssembly через C: замер, а не оценка
Вопрос. Нужна ли flang девятая цель печати — в WebAssembly?
Ответ. Не нужна. WebAssembly уже получается через C, и напечатанный C переезжает туда без единой правки. Весь корпус — 94 программы, 8799 точек сетки — отвечает в WebAssembly ровно то же, что и обычная сборка: значения, коды отказов и тексты диагностик совпадают побайтово. Цена — не новый код, а два флага сборки и короткий список того, что не переезжает и не должно.
Замер сделан на ветке work/wasm-cherez-c от 6e186c5.
1. Что стояло на машине
| версия | откуда | |
|---|---|---|
| clang | 21.1.8 (Ubuntu), цели wasm32, wasm64 | уже стоял |
| wasi-libc | 0.0~git20250726.3f7eb4c-4 | уже стоял, пакет Ubuntu |
| libclang-rt-dev-wasm32 | 1:21.1.8-6ubuntu1 | уже стоял |
lld (wasm-ld) | 1:21.1.6-71 | уже стоял |
| Node | v26.7.0, встроенный node:wasi (preview1) | уже стоял |
| wasmtime | 47.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 из 94 | 8612 | 0 | 2 |
node:wasi | 1 МиБ | 94 из 94 | 8799 | 0 | 0 |
| wasmtime | 1 МиБ модуль + 8 МиБ среда | 94 из 94 | 8799 | 0 | 0 |
Расхождений нет ни одного ни в одном прогоне. Две программы, упавшие при стеке по умолчанию, — не расхождение семантики, а следствие §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 ошибок компиляции, две причины:
- Сигналы.
sigaction,SIGINT,sigemptyset,sig_atomic_t— Ctrl-C, который бросает набранное, но не сессию. - **
mkdtemp** — временный каталог, куда оболочка печатает и собирает код, чтобы что-нибудь вычислить.
Это не лечится флагами, и проверено, а не предположено:
-D_WASI_EMULATED_SIGNAL— не помогает, остаются те же 5 ошибок;-D_GNU_SOURCEвдобавок — не помогает;mkdtempобъявлен в/usr/include/wasm32-wasi/stdlib.h, но **вlibc.aего нет** (nm --defined-onlyне находит);sigactionвlibwasi-emulated-signal.aтоже нет; в самом заголовке стоит#ifdef __wasilibc_unmodified_upstream /* WASI has no sigaction */.
И это правильно, а не досадно. Оба вызова нужны оболочке ровно затем, чтобы спрашивать у мира: где 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 рантайма — столько же обычная сборка заводит потоку под объявленный предел глубины. Не круглое число, а то же самое.
Ещё один предел, свой у каждой среды. Глубину вызовов ограничивает не только модуль, но и хозяин, и модуль этого не видит:
| среда | предел по умолчанию | как поднять |
|---|---|---|
| wasmtime | 512 КиБ | -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":"неразборчивые аргументы"}. Обычная сборка на том же входе работает.
Проверено тремя способами, и все три показывают на хозяина:
- Тот же вход из файла вместо трубы — совпадение с обычной сборкой на всех размерах вплоть до 885 КБ.
- Правка в C не помогает. Очевидная догадка — «
read_allпринимает короткое чтение за конец ввода» — проверена сборкой: +3 строки вflang/src/emit/c/flang_cli.c(ждать конца уfeof/ferror, а не у длины), собрано под-Werror, результат не изменился.node:wasiвыставляет настоящий признак конца ввода, и отличить его от «данных пока нет» программе на C нечем. В дерево правка не внесена — она ничего не чинит. - 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 | совпало |
|---|---|---|---|---|---|
| 1000 | 26 | значение | 19 | значение | да |
| 2000 | 130 | значение | 73 | значение | да |
| 4000 | 536 | значение | 452 | значение | да |
| 6000 | 1200 | значение | 1184 | значение | да |
| 8000 | 2114 | значение | 2239 | значение | да |
| 12000 | 4726 | значение | — | отказ | нет |
| 16000 | 8350 | значение | — | отказ | нет |
| 24000 | 18706 | значение | — | отказ | нет |
Стена — между 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 под wasmtime | 56.6 (52.6—65.1) |
Дороже в 15.4 раза, разница 53 мс. Это цена среды: она обязана разобрать и оттранслировать модуль при каждом запуске.
Счёт (то же время за вычетом цены запуска):
| задача | обычная, мс | wasm, мс | wasm медленнее |
|---|---|---|---|
| сортировка вставками, 2000 | 147.3 | 103.3 | 0.70× |
| сортировка слиянием, 2000 | 311.2 | 277.3 | 0.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, и это тысячи строк плюс постоянная стоимость: девятая цель означает девятое место, где расходится семантика.
Что при этом получено даром сверх ожидаемого:
- семантика остаётся нашей. Это прямо закрывает то, что нашло исследование модульности: печатая в JavaScript, мы отдаём программу в чужую семантику. Здесь чужой семантики нет — те же значения, те же коды, те же тексты;
- песочница — модуль запускается внутри чужой программы, видя ровно те каталоги, которые ему выдали, и ничего больше;
- браузер — тот же модуль, другая среда;
- компилятор flang работает в WebAssembly (§5).
Что надо знать тому, кто этим воспользуется, — три пункта, и все три названы числами выше:
- собирать с
-DFL_STACK_ROOM_FALLBACK=8388608u -Wl,-z,stack-size=8388608(иначе §7 — молчаливая порча памяти, а не честный отказ); - брать wasmtime и поднимать ему
-W max-wasm-stack=8388608(иначе §8 — обрыв ввода, и §7 — ранний обрыв рекурсии); - помнить про потолок 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/ — цель печати не написана и не нужна.