T27.AI

Блог

Куда уходит время компиляции t27c, и первый бэкенд без LLVM

2026-10-04 · 10 мин чтения

[один M1 Pro, одна синтетическая программа; цифры t27b сняты при load average 83–165 на 8 ядрах; t27b понимает 37 из 1174 спек, а его JIT работает только на arm64 macOS; три из четырёх найденных им ошибок ещё не исправлены] t27c проверяет типы одной функции за 29,9 мкс, в 5,2 раза быстрее rustc check, но машинного кода не выдаёт, поэтому его полный путь до объектного файла в 2,1 раза медленнее clang на той же программе, написанной на C. Чистая release-сборка самого t27c сократилась со 142,7 с до 66,0 с после трёх влитых PR, убравших неиспользуемые библиотеки машинного обучения и сборку aws-lc-sys. t27b, собственный бэкенд AArch64 без LLVM, уже влит: он компилирует и запускает те же тесты в 23–381 раз быстрее пути через Zig, пишет код примерно уровня clang -O0 и нашёл четыре ошибки в t27c и в одной спеке.

#t27#Compiler#Measurement

t27c, компилятор t27, — транслятор. Он читает спеку .t27 и печатает Zig, C, Rust или Verilog, а машинный код из этого делает другой компилятор, почти всегда LLVM. Здесь три вопроса. Насколько быстра та часть, которую t27c делает сам? Сколько стоит весь путь, если считать бэкенд? И сколько собирается сам t27c — ведь это программа на Rust? В конце — наш собственный маленький бэкенд t27b, который выдаёт код AArch64 без LLVM, и четыре ошибки, которые он нашёл по дороге.

Всё замерено 4 октября 2026 года на одном Apple M1 Pro (8 ядер, 16 ГБ). Тестовая программа синтетическая. Ограничения перечислены в конце, и они существенны.

Почему Rust и C++ собираются долго

Жалоба старше Rust. Go в Google придумывали в том числе потому, что большая сборка на C++ шла 45 минут. По рассказу Пайка 2012 года, 4,2 МБ исходников на C++ после раскрытия всех #include превращались больше чем в 8 ГБ — примерно в 2000 раз больше. Наш тестовый файл показывает то же в малом: шесть стандартных заголовков после clang++ -E превращают 1007 строк в 88 104.

У Rust цена другая. Обобщённый код компилируется заново для каждого конкретного типа, а единица компиляции — целый крейт. Чтобы занять ядра, rustc делит крейт на единицы кодогенерации (CGU), но один модуль не делит никогда. На t27c это видно напрямую. bootstrap/src/compiler.rs — 44 636 строк в одном модуле, его CGU весит 880 КБ, и 98,5% из них — этот один файл. По -Z time-passes на release-сборке крейта t27c rustc 36,6 с из 60,7 с ждал LLVM. Машина во время этого прогона была нагружена.

Дальше сам LLVM. В нашем тесте фронтенд clang (-fsyntax-only) занимает 24,5% времени clang -O0. Остальное — IR, кодогенерация LLVM и запись объектного файла. Оптимизации стоят ещё больше: -O2 идёт в 6,4 раза дольше -O0 у clang на C и в 3,7 раза дольше у rustc. Нагляднее всего Zig. Со своим бэкендом (-fno-llvm) он собирает тот же файл в 5,7 раза быстрее, чем через LLVM. Опубликованные бэкенды без LLVM дают выигрыш того же порядка: Copy-and-Patch (OOPSLA 2021) компилирует примерно в 100 раз быстрее LLVM -O0, TPDE (CGO 2026) — в 8–24 раза.

Об одном пробеле в литературе стоит сказать прямо. Рецензируемой работы, которая технически измеряет время компиляции Rust, мы не нашли. В опросе проекта Rust о скорости компилятора 2025 года больше 3700 ответов, и 55% опрошенных ждут инкрементальную пересборку дольше 10 с. Это пост в блоге, а не статья. Все цифры о Rust ниже — наши собственные замеры.

Как мерили

t27c проверяет функцию за 29,9 мкс

101001000clang -fsyntax-only (C)24,4t27c typecheck29,9t27c gen-c33,7zig -fno-llvm82,1go tool compile95,2clang -O0 (C)99,6rustc check154,4rustc -O0291,6zig Debug (LLVM)468,4clang -O2 (C)636,0rustc -O21089,5swiftc -typecheck2084,6t27cтолько проверка, без кодамашинный кодмкс на функцию, лог. шкала
Микросекунды на функцию при N = 5000, медиана 3 запусков, логарифмическая шкала. Золото — t27c. Светлые полосы только проверяют код, тёмные выдают машинный код. Это разная работа, и в таблице ниже она разведена.
КомпиляторЧто делаетмс при N = 5000мкс на функцию
clang -fsyntax-only (C)разбор и проверка C, без кода121,924,4
t27c typecheckразбор и проверка типов t27, без кода149,729,9
t27c gen-cразбор, проверка и печать исходника на C168,433,7
zig -fno-llvmсобственный бэкенд Zig, Debug, без LLVM410,382,1
go tool compileсобственный бэкенд Go476,195,2
clang -O0 (C).o через LLVM без оптимизаций498,199,6
rustc checkтипы и заимствования, только метаданные772,1154,4
rustc -O0.o через LLVM без оптимизаций1457,8291,6
zig Debug (LLVM).o через LLVM, Debug2341,9468,4
clang -O2 (C).o через LLVM, -O23180,2636,0
rustc -O2.o через LLVM, opt-level=25447,61089,5
swiftc -typecheckпроверка типов Swift, без кода10 423,22084,6

Среди тех, кто только проверяет код, t27c typecheck на втором месте: 29,9 мкс на функцию против 24,4 у clang. Это в 5,2 раза быстрее rustc check и в 69,6 раза быстрее swiftc -typecheck. Печать исходника на C поверх проверки стоит немного: t27c gen-c — 33,7 мкс.

Но машинного кода t27c не выдаёт

Поэтому честное сравнение — весь путь: t27c плюс бэкенд, который работает с тем, что t27c напечатал.

Путь, N = 5000t27c, мсбэкенд, мсвсего, мсдоля t27c
C, написанный сразу: clang -O0—498,1498,1—
t27 → C → clang -O0168,4899,61068,015,8%
Zig, написанный сразу: zig Debug (LLVM)—2341,92341,9—
t27 → Zig → zig test (сборка и линковка)167,93370,43538,44,7%

Самый быстрый путь t27 до машинного кода идёт через C: 1068,0 мс. Та же программа, написанная на C вручную, собирается за 498,1 мс, в 2,1 раза быстрее. Большая часть разницы — не t27c. Сгенерированный C — 80 043 строки против 50 001, потому что в нём ещё 5000 тестов. В пути через Zig на бэкенд уходит 95,3% времени. Пока t27c зависит от чужого бэкенда, его полный путь никогда не быстрее этого бэкенда.

Сборка самого t27c: 142,7 с → 66,0 с

t27c — программа на Rust, и всё сказанное в первом разделе относится и к нему. Чистая release-сборка шла 142,7 с по часам. Отчёт cargo о времени показывает, куда оно уходило:

Единица сборкиВерсияСекундыСудьба
aws-lc-sys (build script)0.41.078,8убрано в #5920
candle-core0.11.052,4убрано в #5900
candle-core0.10.249,4убрано в #5900
t27c (сам компилятор)0.4.034,4осталось
tokenizers0.22.233,3убрано в #5900
candle-nn0.10.214,6убрано в #5900

Самая долгая единица — сборочный скрипт aws-lc-sys, криптобиблиотеки на C, которая приходила через reqwest → rustls → aws-lc-rs. Дальше две версии candle-core и tokenizers — библиотеки машинного обучения, которые в bootstrap/src нигде не использовались. Сам крейт t27c — 34,4 с. Большую часть этого убрали три влитых PR:

до изменений142,7 с · 386 единиц сборкибез candle (#5900)97,9 с · 279 единиц сборки+ native-tls (#5920)66,0 с · 273 единиц сборкита же ветка #5900, повторно110,9 с · 279 единиц сборки
Чистая release-сборка t27c по часам, по одному прогону. Пунктир — та же ветка #5900, собранная второй раз: 110,9 с вместо 97,9 с. На столько может сдвинуться один прогон на этой машине, так что полосы показывают направление, а не точную цифру.

Чистая сборка бывает редко, а правка одной строки и пересборка — постоянно. Обычная release-пересборка после правки одной строки шла 29,8–33,9 с: release не инкрементальный, и большая единица compiler.rs стоит на критическом пути. С CARGO_PROFILE_RELEASE_INCREMENTAL=true — 3,2–5,0 с, а cargo check — 2,4–3,3 с. #5945 (влит) записал этот цикл в CONTRIBUTING.

Одна идея не сработала. Мы разбили compiler.rs на 24 файла, и вывод компилятора остался тем же побайтно. Потом собрали крейт t27c 6 раундами вперемешку. Медиана сдвинулась с 49,6 с до 37,8 с, минимум — с 34,1 с до 32,1 с, а процессорное время выросло с 95,9 с до 98,4 с. Разбиение было быстрее только в 3 парах из 6, а отдельные сборки шли от 32 до 117 с. Это шум, а не ускорение. Ветка остаётся локальной и PR не стала.

t27b: свой бэкенд для части языка

t27b — отдельный крейт cli/t27b, влит в #5979 (следом влит #5989, который вписал его в CI-реестр крейтов-сирот). Он сам выдаёт машинный код AArch64, без LLVM, zig, clang и rustc. Фронтенд у него — разборщик и проверка типов самого t27c, подключённые как есть. Дальше код новый: своё промежуточное представление, кодировщик команд A64, а затем либо исполнение в памяти (t27b test, JIT), либо объектный файл Mach-O (t27b build). Переполнение целых определено: ловушка или перенос (--overflow trap|wrap), а сдвиг на величину вне [0, ширина) — ловушка. Эталон для проверки — свой интерпретатор того же представления. JIT работает только на arm64 macOS.

Эти цифры сняты в другом сеансе, не в том, что выше. Машина была сильно нагружена: load average от 83 до 165 на 8 ядрах. Поэтому абсолютные времена завышены. Каждое отношение ниже сравнивает варианты, которые гонялись вперемешку в одном сеансе, по 5 раз каждый.

10 мс100 мс1000 мс10 000 мсN = 100load average83–13417,8 мс1158 мс · ×65,06798 мс · ×381,5N = 1000load average129–16159,6 мс1189 мс · ×19,96124 мс · ×102,7N = 5000load average98–165917 мс4529 мс · ×4,921 058 мс · ×23,0t27b testgen-c + clang + запускgen + zig test
Компиляция и запуск тех же тестов, медиана 5 прогонов, логарифмическая шкала. «×» — во сколько раз дольше, чем t27b. Load average под каждым N показывает, насколько машина была занята.
Nload avgt27b test, мс (медиана / мин)gen-c + clang + запуск, мсдольше t27b (медиана / мин / CPU)gen + zig test, мсдольше t27b (медиана / мин / CPU)
10083–13417,8 / 15,81158×65,0 / ×59,0 / ×14,46798×381,5 / ×349,2 / ×245,7
1000129–16159,6 / 51,11189×19,9 / ×15,8 / ×8,66124×102,7 / ×61,9 / ×67,4
500098–165916,7 / 250,14529×4,9 / ×8,3 / ×6,421 058×23,0 / ×20,0 / ×24,9

Против пути через Zig t27b быстрее в 381 раза при N = 100 и в 23 раза при N = 5000 (медианы). По процессорному времени — в 246 и 25 раз. Медианное отношение для пути через C завышено нагрузкой. При N = 100 запуск ./ctest занял 970,3 мс по часам и всего 6,7 мс процессора — большую часть времени он ждал. Если считать только компиляцию, путь через C идёт в 10,5 раза дольше t27b при N = 100 и в 4,3 раза при N = 5000. Нагрузка видна и по zig test: при N = 5000 здесь он шёл 20,5 с, а в более спокойном прогоне выше — 3,4 с. Поэтому t27b нет в таблицах выше.

Объектный файл

t27b build пишет .o, как clang -c. Тот же сеанс, медианы в миллисекундах:

Nt27b build (trap)gen-c + clang -O0 -cgen-c + clang -O2 -cC вручную, clang -O0 -c
10021,4120,3 (×5,6)301,2 (×14,1)74,4 (×3,5)
100062,9570,5 (×9,1)1780,1 (×28,3)190,2 (×3,0)
5000822,73847,7 (×4,7)20 388,9 (×24,8)1496,0 (×1,8)

Сравнение несимметричное: clang делает гораздо больше работы, чем t27b. Но место t27b оно показывает. При N = 5000 он быстрее, чем clang на C, написанном вручную, с -O0.

Куда уходит время t27b

Фаза, N = 5000, медиана 15мсдоля
разбор (общий с t27c)157,248,3%
проверка типов (общая с t27c)90,827,9%
перевод в своё IR37,811,6%
генерация кода A6417,15,3%
отображение в память и запуск1,60,5%
всего, вместе с чтением файла325,8100,0%

76,1% времени уходит на фронтенд, общий у t27b с t27c. Собственная часть t27b, от перевода в IR до запуска, — 17,3%. Значит, ускорять t27b дальше — это ускорять фронтенд t27c.

Скорость получившегося кода

Nt27b trapt27b wrapgen-c + clang -O0gen-c + clang -O2
1009,407,3611,153,96
100010,368,4712,464,93
500010,958,5013,655,30

Наносекунды на вызов, медиана 5 прогонов. Контрольные суммы всех вариантов совпадают. Код t27b — примерно уровень clang -O0. В режиме trap он тратит 0,80–0,84 времени gen-c с -O0. В режиме wrap он в 1,60–1,86 раза медленнее gen-c с -O2. Оптимизатора в t27b нет. При N = 5000 секция кода — 400 000 байт в режиме trap и 319 992 в режиме wrap, против 963 612 и 523 608 у gen-c с clang при -O0 и -O2.

Корректность и покрытие: 37 из 1174

В дифференциальном тесте каждую функцию вызывали на одних и тех же входах из кода t27b и из кода clang. Режим wrap сравнивали с -O2, режим trap — с -O0 и ловушками UBSan на переполнение. На 12 261 000 вызовах расхождений 0. Это одна синтетическая программа, а не доказательство.

t27b corpus прошёл по всем 1174 спекам репозитория за 3,6 с. Понимает t27b 37 из них. 36 проходят, причём в 26 из них тестов нет вовсе; 1 падает из-за настоящей ошибки в спеке (ниже). Отказ — 1117 файлов, общий фронтенд споткнулся на 20. Расхождений JIT с интерпретатором, зависаний и падений не было. Чаще всего t27b отказывает (считается первый отказ в файле) на StructDecl (393), string literal (287), EnumDecl (89), InvariantBlock (70), type str (31). Структуры, строки, перечисления, блоки invariant и приведения — задача #5977, она открыта. Первый PR по покрытию, #5992, запускает блоки invariant как тесты и считает их отдельно; он отправлен, но не влит.

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

Чистая release-сборка самого t27b — 17 единиц сборки и 8 крейтов против 386 и 295 у t27c. Бинарник — 1,37 МБ против 15,6 МБ. Время его сборки при нагрузке 244–336 мало что значит: медиана 3 прогонов 111,6 с, минимум 67,2 с. Большая часть исходника, который он компилирует, не его: он подключает compiler.rs (44 636 строк) и use_resolve.rs (1010) рядом с примерно 5100 строками своего кода.

Почему он быстрый — не загадка. По той же причине Zig со своим бэкендом в 5,7 раза быстрее Zig через LLVM, и на том же стоят Copy-and-Patch и TPDE: один проход, без LLVM, код уровня -O0.

Что t27b нашёл

Второй бэкенд с определённой семантикой полезен уже тем, что его ответы можно сравнить с ответами t27c. Так нашлись четыре ошибки. Исправлена пока одна.

ОшибкаКак нашлиСостояние на 4 октября 2026
ternary_model.t27: функция dot27 сдвигает i32 на величину до 52 бит, а её тест ожидал неверные значениякорпус t27b: «shift amount out of range at line 40»исправлено: issue #5972 закрыт, PR #5975 влит 4 октября
gen-c пишет обычный C (x + 1, x >> n). Знаковое переполнение и сдвиг шире типа в C — неопределённое поведение, и тесты inc_max и shr_big меняют исход между clang -O0 и -O2пробный модуль, три бэкенда рядомв работе, ещё не PR
t27c gen (Zig): знаковый % не компилируется, Zig требует @rem или @modтот же пробный модульотправлено, не влито: PR #5993
разборщик t27c читает module a.b; и use std.testing; как a плюс отдельное выражение .b без номера строки; так разобраны 12 спеккорпус t27b: отказ на «StmtExpr» в строке 0в работе, ещё не PR

Главная из них — вторая. Одна и та же спека значит разное в зависимости от бэкенда и уровня оптимизации. Zig ловит переполнение, t27b тоже, а C, сгенерированный t27c, молча делает то, что решит оптимизатор. Исправление для Zig — PR #5993, отправлен, не влит. Исправления gen-c и разборщика лежат в локальных ветках и PR не стали, поэтому здесь они записаны как «в работе».

Чего это не показывает

Чего это не решает

Пруфы

Поработаем вместе

Нужно довести FPGA/RTL-задачу до замеров на железе?

Работаю по контракту и part-time с hardware-AI, FPGA/RTL и ML-системами — от спецификации и открытого тулчейна до воспроизводимых измерений.