Сразу прошу прощения за несколько надоевший всем стиль «lytdybr», но уж очень хочется поделиться крайне приятным опытом и рассказать о по-своему замечательном компиляторном курсе. И это ещё хорошо, что я пишу сейчас, когда эмоции подугасли, а не когда я только закончил вторую главу курса и от эйфории чувствовал себя как «хомячок, которого капля никотина разрывает на части»! Сразу предупреждаю, наверняка для кого-то эта заметка — «ребёнок познаёт мир», тех прошу сразу закрыть вкладку и не судить строго. Здесь и далее, всегда и всюду, во всех четырёх сферах прошу учитывать, что я не только не создаю компиляторы, но даже и не обучаю этому и не пишу методички! ;-)
Как всем известно, типичный компилятор — это консольная программа, читающая текстовый файл на некотором языке и выплёвывающая либо поток ругательств в stdout, либо бинарный файл на другом языке в какой-нибудь файл. Опять-таки, не секрет, что внутри типичный современный компилятор разделён на части: если грубо, то frontend (парсер), middleend и backend (кодогенератор), каждая из которых обрабатывает информацию и передаёт дальше. То есть, обычно компилятор — это некоторая последовательность из процедур (на жаргоне «проходов»), преобразующих программу.
Обычно первый проход — это парсер, на вход он получает текст, а выдаёт «дерево». И с этого момента обрабатываемая программа хранится и передаётся между проходами, как правило, в виде деревьев или списков. Nanopass — это подход к проектированию компилятора, в котором архитектура компилятора базируется на идее последовательности чётко очерченных отдельных «проходов». То есть, компилятор явно представляется как последовательное применение к обрабатываемой программе большого числа проходов с чётко, даже формально, описанными интерфейсами. Такая архитектура уменьшает кол-во ошибок, упрощая (само)обучение, поэтому она особенно хорошо подходит для преподавания.
Помимо общего подхода есть ещё и одноимённый framework на Scheme, упрощающий построение компиляторов на базе этого подхода — https://nanopass.org/. Этот framework хвалят, но конкретно я им ещё пока не пользовался и сказать ничего по этому поводу не могу.
Этой осенью я писал компилятор по курсу «Essentials of Compilation» университета Индианы, о чём далее.
Nanopass
Откуда я вообще узнал про подход Nanopass? Тут я обязан поблагодарить Петра Советова (true-grue) за его работу по популяризации компиляторостроения, курирование телеграм-канала CompilerDevelopment и создание подборки курсов по компиляторам — https://github.com/true-grue/Compiler-Development (просьба профессионалам добавлять туда интересное и подходящее).
Подход Nanopass берёт своё начало со статьи «An Incremental Approach to Compiler Construction», https://raw.githubusercontent.com/namin/inc/master/docs/paper.pdf, развитой в «A nanopass framework for commercial compiler development» https://dl.acm.org/doi/10.1145/2500365.2500618. Насколько я понимаю, для коммерческого применения этот подход слегка хуже обычного, т.к. компилятор из-за огромного кол-ва проходов получается несколько медленнее. А вот для обучения, подход Nanopass, как мне кажется, прекрасен. Не забывайте, что я не профессионал ни в компиляторах, ни в обучении CS!
Немного повторюсь, в рамках Nanopass компилятор — это некоторое последовательное применение процедур-проходов к тексту программы. Первая процедура — это парсер, последняя — кодогенератор, на псевдокоде что-то вроде:
compiler = emit . add_prelude_conclusion .
patch_instruction . assign_homes . select_instructions .
explicate_control . remove_complex_operands .
uniquify_variables . parse
Nanopass — это старый добрый Unix-way, то есть, каждый проход делает лишь одну вещь и делает её хорошо. Каждый проход — это функция из одного промежуточного языка в другой, причём эти языки мало того, что описаны, в рамках подхода желательно написать интерпретатор для каждого языка. Так, чтобы можно было «запустить» каждое из промежуточных представлений компилируемой программы и сравнить с эталоном. Это жесткосердный подход, но от компиляторов и требуется очень высокая надёжность: падающий с SEGFAULT компилятор — это позор, а неправильная кодогенерация — это потерянные человеко-годы в отладке. Хотя SPJ рассказывал байку о том, что он как-то выпустил версию GHC, которая стирала исходник компилируемой программы, если тот содержал ошибку. И за это SPJ даже не обматерили — «святые люди, эти хаскелисты» заключил он.
В общем, как обычно в софтостроении: «тише едешь — дальше будешь», «quality is free», «чем позже найдена ошибка, тем она разорительнее» и прочие скучные мудрости ушедших веков.
Возвращаясь к примеру, проход explicate_control переводит код с LISP-подобного языка в трёхадресный код:
explicate_control :: RestrictedLISP -> ThreeAddressCode
И проверяется с помощью двух интерпретаторов (один до, а второй — после прохода):
interp_ThreeAddressCode :: ThreeAddressCode -> ReturnCode
interp_RestrictedLISP :: RestrictedLISP -> ReturnCode
Естественно, если эти интерпретаторы кто-то другой уже написал, жизнь превращается просто в сказку.
Курс Essentials of Compilation
Хотя есть несколько разных курсов, построенных на этом подходе, я их не сравнивал, взяв курс со страницы true-grue, то есть https://github.com/IUCompilerCourse/Essentials-of-Compilation на языке Racket. У курса есть ещё официальная версия на Python'е и неофициальная версия на Ocaml. И хотя более-менее владею всеми тремя, я решил, возможно с наивной верой в людей, что курс на Racket будет содержать меньше косяков. Пётр Советов особенно подчёркивал, что в этом курсе парсер и кодогенератор (pretty-printer) уже написаны, поэтому с ними мороки и потери времени не будет (парсеры я писал, мне это скучно).
Основной автор курса, Prof. Jeremy G. Siek относительно недавно курс существенно переделал, и в нынешней редакции широко используется механизм структур Racket, который немного напоминает алгебраические типы данных. Кое-кто из моих знакомых, любителей Racket, от структур плюётся, но мне понравилось. Книга для старой версии курса доступна для скачивания в финальном виде — https://jeapostrophe.github.io/courses/2018/spring/406/notes/book.pdf, мне же пришлось скомпилировать тексты текущей редакции (ветка master).
К современной редакции курса идёт «обвязка» — https://github.com/IUCompilerCourse/public-student-support-code Она содержит код скриптов, запускающих тестирование, набор интерпретаторов и typechecker'ов для всех промежуточных языков, а также наброски структуры компилятора, чтобы начинать не совсем с чистого листа, чистый лист пугает.
Структура курса.
В рамках курса, опять-таки, архитектурно очень красиво с точки зрения «спиральной методологии» мы пишем скелет компилятора, на который позже навешиваем мясо. Мы расширяем компилятор как по количеству проходов, так и по языку обрабатываемых программ, который становится всё мощнее и сложнее от главы к главе.
Процесс обучения выглядит так:
- Клонируем public-student-support-code куда-то на локальную машину.
И, чтобы не гадить автору курса (ему же ещё домашку проверять), ни в коем случае
не выкладываем свой код в публичный репозиторий.
Далее мы будем модифицировать файлы run-tests.rkt, compiler.rkt и файлы в каталоге tests.
Соответственно, в цикле по главам:
- Читаем очередную главу из книги, может даже несколько раз.
- Добавляем в каталог tests некоторое кол-во тестов для новых возможностей
компилятора и языка. - Подправляем уже реализованные проходы и добавляем новые.
- Пишем дополнительные тесты, убеждаемся, что всё, тем не менее, работает.
- Следующая глава, goto пункт 1.
Это, в общем и целом стандартно. А вот что круто — это то, что поскольку все интерфейсы очень хорошо определены, для результата каждого прохода есть интерпретатор, уже написанный авторами курса. Ура, мы попали в сказку! И эта сказка добрая! То есть, после каждого прохода мы можем проверить операционную семантику программы и убедиться в том, что проход ничего не испортил — программы до какого-нибудь register_allocation и после него «эквивалентны», хотя и написаны на разных языках. Более того, прилагающаяся к курсу тестовая обвязка всё это делает — отлаживать легко и приятно (а не как всегда).
Но самое замечательное в этом курсе, то, что привело меня буквально в состояние эйфории — после каждой, даже «самой первой» главы, у нас в руках работающий компилятор. Причём не жульничество вроде компилятора с какого-то модельного языка на какой-то модельный язык. Нет, это компилятор пусть и с подмножества LISP в настоящий, абсолютно реальный, совершенно честный, без гвоздей, ассемблер x86-64. Причём код скриптами из public-student-support-code автоматически скармливается системному ассемблеру и ld, выдавая на выходе настоящий бинарник (в моём случае Mach-O). Представляете — настоящий бинарник, как у взрослых!
Поскольку я, как и многие «зрелые промышленные практики с ипотекой и детьми, пишущие на С++ уже лет 20» на самом деле являюсь «вайтишником», я на «кухню компиляторов хожу только кушать», и компиляция для меня всегда имела налёт магии. Да, парсеры я, разумеется, писал, даже недавно экспериментально выяснил, что парсер для LISP на Parsec у меня занимает 2 (два) часа (см известную книгу «Write Yourself a Scheme in 48 Hours»). Но компилятор — это не только парсер, вернее, в основном это совсем даже не парсер, а совершенно другое. И когда эти мои триста строк PLT Scheme получили на вход программу «калькулятора», а выплюнули рабочий ассемблер, тут же скомпилированный в работающий под OSX бинарник, моему восторгу не было предела. Я бегал кругами и пугал домашних воплями о том, что «у меня есть настоящий компилятор, который я понимаю от начала и до конца». И это была правда.
Конечно, язык, с которой компилирует результат первой «реальной» главы (вообще-то второй после Preliminaries) — это язык арифметических выражений с + и * да скобками. Не поражает, если им пользоваться. Типичная программа:
(+ (+ 3 4) (+ 14 21))
Она же в оттранслированном виде:
.align 16
_start:
movq $14, %rcx
addq $21, %rcx
movq $3, %rdx
addq $4, %rdx
movq %rdx, %rax
addq %rcx, %rax
jmp _conclusion
.globl _main
.align 16
_main:
pushq %rbp
movq %rsp, %rbp
subq $0, %rsp
movq $65536, %rdi
movq $65536, %rsi
callq _initialize
movq _rootstack_begin(%rip), %r15
movq $0, 0(%r15)
addq $8, %r15
subq $256, %rsp
jmp _start
.align 16
_conclusion:
subq $8, %r15
addq $256, %rsp
popq %rbp
retq
Поскольку я «зрелый практик с ипотекой и детьми», писать получается нечасто, одна глава у меня занимает примерно две недели, не сильно больше и не сильно меньше. То есть, через неделю-другую, у меня появляется очередное «разоблачение чёрной и белой компиляторной магии».
Разумеется, чем дальше, тем всё менее и менее поразительны обсуждаемые вещи. Например, глава про сборщик мусора по какой-то причине мне оказалась совсем неинтересна, хотя кое-что новое я почерпнул. Но это ожидаемо, что восторг первых глав уйдёт. В конце концов, тот же gradual typing, рассматриваемый в главе 10 — это вообще слишком близко к переднему краю исследований, чтобы рядовой программист был об этом наслышен и, следовательно, заинтересован.
Теперь о грустном, то есть, о косяках. Косяки есть, причём в количестве. Курс меняется, то тут, то там появляются какие-то недосказанности или вообще ошибки, но их лечат. Основной автор курса доступен по электронной почте, очень доброжелателен, но, увы, и очень занят. Опять-таки, как я понимаю, основной упор делается на версию для Python'а, что немного странно: все промежуточные языки там так или иначе LISP-подобны, поэтому оперировать со скобочками придётся. Но я же не преподаватель, им там виднее, в их Индианах.
Ещё, конечно, немножечко портит жизнь моё неважнецкое знание Racket'а: к примеру, я не знал, про pretty-print, аналогичный show из Haskell. Но, слава богу, Racket — это не C++, язык прост; большая часть необходимых библиотек содержится в стандартной поставке Racket; курс требует лишь небольшого подмножества языка (похожего на смесь SICP Scheme и «core» ML — ядра SML/Ocaml). Жить можно, в общем.
Заключение
Мне очень понравился курс построения компиляторов Essentials of Compilation в текущей редакции. Курс чудесно разоблачает «компиляторную магию», поскольку структура компилятора всегда под рукой, а все преобразования остаются понятными и логичными. Он очень, очень, очень подходит как курс повышения квалификации «вайтишникам с многолетним опытом», которые комплексуют, как я, от того, что не имеют полноценного профильного образования (я знаком с реальным хорошим CS PhD — это круто, совершенно другой уровень). Конечно, курс стал очень длинным, и, видимо, мало кто может себе позволить пройти его до конца. Да, пожалуй, и не надо. Но в любом случае не повредит пройти до главы 4 (Booleans and Conditionals).
Порядок глав, с моей точки зрения, довольно разумен, но лично я бы предпочёл, чтобы разговор о функциях шла перед реализацией сборки мусора. Тогда получился бы «расширенный язык C», а так после главы 6 получается странный язык, похожий по семантике на BASIC, но почему-то с кортежами и сборщиком мусора. Но, возможно, я что-то важное упускаю из вида.
Ещё не понравилась стандартная современная ошибка архитекторов систем, когда «ремонтопригодность» не принимается во внимание. К сожалению, есть только сложный «драйвер» компилятора, прогоняющий все имеющиеся тесты. Это замечательно, что он есть, но хотелось бы иметь и простой «драйвер», позволяющий оттранслировать одну и только одну программу до заданной стадии. Ещё хотелось бы разделить единый compiler.rkt на файлы по одному на проход.
Но это всё придирки — в остальном курс очень хороший, приятный, «структурирующий мир и делающий его понятнее». Я не знаю, как там для обучения индианских студентов, но как курс повышения квалификации он очень хорош. И даже некоторая недопиленность лишь делает курс более приближенным к реальной жизни, да и вообще «— Общение с девушками доставляет удовольствие лишь в тех случаях, когда достигается через преодоление препятствий…»
Комментарии (19)
sshikov
22.02.2022 21:58+3парсер для LISP на Parsec у меня занимает 2 (два) часа
Слушайте, но это же в первый раз, очевидно? Все-таки парсек — это близкий аналог регулярок, и в него надо хоть немного вникнуть.vkni Автор
22.02.2022 22:13Ну интерпретатор LISP'а я писал первый раз. Парсер на именно Parsec — кажется второй раз. Я не предлагаю писать парсеры на скорость, и даже не особо хвастаюсь, просто так получилось.
Парсер — это самое там скучное, поэтому прекрасно, что он остался за бортом!
0xd34df00d
23.02.2022 03:44+1это близкий аналог регулярок
Эээ, ну только разве что тем, что и там, и там какой-то парсинг.
Полный монадический парсек может парсить как минимум контекстно-зависимые языки (например, общеизвестно контекстно-зависимая грамматика a^n b^n c^n им легко выражается). Парсек в аппликативном контексте — контекстно-свободный (и то с конечной грамматикой, а с бесконечной, что выразимо на хаскеле, можно в аппликативном контексте парсить и некоторые КЗ-языки).
vkni Автор
23.02.2022 05:57Ну да, что и там, и там какой-то парсинг + это прямо встроено в код, а не используется генератор аля bison.
Меня всё время интересовал вопрос - чем отличаются на примере аппликативный и монадический парсеры. Скажем, вот ту же грамматику LISP может съесть аппликативный парсер? А Cшную?
0xd34df00d
23.02.2022 06:26+3Меня всё время интересовал вопрос — чем отличаются на примере аппликативный и монадический парсеры.
Аппликативный парсер — это который использует только методы
Applicative
(то бишь,<$>
,pure
и<*>
). Монадический может использовать(>>=) :: m a -> (a -> m b) -> m b
, или, специализируя для какого-нибудь псевдопарсера,Parser a -> (a -> Parser b) -> Parser b
.Что означает этот тип? Что вы можете взять парсер, возвращающий тип
a
, применить его, получить какое-то конкретное значение типаa
, и на базе него сконструировать парсер, возвращающий типb
.Если приводить примеры, то в монадическом стиле можно написать (псевдокодом, это не какая-то конкретная библиотека)
csgExample :: P.Parser () csgExample = do as <- P.takeWhile (== 'a') let n = length as P.takeWhileN (== 'b') n P.takeWhileN (== 'c') n
где
takeWhileN :: (Char -> Bool) -> Int -> P.Parser ()
пытается применить соответствующий предикат ровноn
раз, и
что распознаёт то самое выражение anbncn, и что у вас не получится никак написать (конечным) аппликативным парсером.В аппликативном случае у вас получится максимум написать
foo :: P.Parser Bool foo = f <$> P.takeWhile (== 'a') <*> P.takeWhile (== 'b') <*> P.takeWhile (== 'c') where f as bs cs = length as == length bs && length bs == length cs
но это не эквивалентный парсер — он всегда успешен, просто он вместо юнита возвращает
Bool
.Зачем нужны аппликативные парсеры, если есть строго более выразительные монадические? Монадические невозможно анализировать (и оптимизировать, например) без того, чтобы их запустить, а там вылезает всякий Тьюринг уже, потому что функция
a -> m b
может быть произвольной. Аппликативные парсеры же анализировать можно, потому что их структура и форма никогда не зависит от конкретных распаршенных данных. Да, функцияa -> b
внутриf (a -> b)
, которое потом будет<*>
-применено кf a
, тоже может быть произвольной, но она не влияет на структуру парсера.К слову, это применимо не только к парсерам, но и к любым другим вещам, которые ложатся на монадические и аппликативные абстракции. Системы сборки (Neil Mitchell много про это писал), предметно-специфичные языки, етц — аппликативные вещи существенно менее выразительны, но их можно статически анализировать.
Скажем, вот ту же грамматику LISP может съесть аппликативный парсер? А Cшную?
Если грамматика контекстно-свободная, то может.
vkni Автор
23.02.2022 08:06Сшная, увы, не контекстно-свободная. Блин, надо медитировать над известной статьёй про Functional Pearl... Что-то у меня в голове не перещёлкивается с applicatives.
Спасибо за примеры, буду думать.
sshikov
23.02.2022 09:28+1>Эээ, ну только разве что тем, что и там, и там какой-то парсинг.
Понятно что это не эквивалент. Я имел в виду, что даже на то, чтобы освоить регулярки, которые более простые, нужно какое-то время (у некоторых это вообще не получается сделать). Поэтому два часа на написание парсера лиспа — вполне нормальное время для первого раза.
Rutel_Nsk
23.02.2022 07:17Курс «компиляторостроения» преподается в рамках какого то «учебного процесса» в университете или другом учебном заведении?
Когда учился в НЭТИ, там был полугодовой курс создания компилятора (примерно 90 листов конспекта). Мне было очень интересно.
Если есть желание создать компилятор для DataFlow системы (компьютер 5 поколения), то пишите (будет совсем новый опыт).vkni Автор
23.02.2022 08:04Курс «компиляторостроения» преподается в рамках какого то «учебного процесса» в университете или другом учебном заведении?
Да, в Indiana University Bloomington - https://www.indiana.edu/
Вот страница курса: https://iucompilercourse.github.io/IU-Fall-2021/Rutel_Nsk
23.02.2022 08:43-1А «буржуев» есть активный проект по созданию компьютера поколения 5 тип: DataFlow?
Создание нейронного компьютера — не интересно (не является универсальным вычислителем).
forthuser
23.02.2022 09:20На странице курса некоторые ссылки на дополнительные материалы не валидны.
P.S. А, не рассматривалась ли возможность, к примеру, включения в курс и темы применения конкатенативных языков Форт направленности (Factor) на каком то «уровне» курса/примеров?
К примеру, в книге компиляторов построения от Серебрякова трансляция производится в стековый вычислитель.
А в этом проекте — Forthress — приложениe к книге низкоуровневого программирования для x86-64, Форт выбран как иллюстрирующий материал.
Есть и такой проект cc64 cc64 is a small-C compiler, written in Forth (here's why), targeting the 6502 CPU. (возможно выглядит в чём то «наивным» в сделанном варианте)
true-grue как мне известно Форт тоже использует или использовал в том или ином варианте в своей профессиональной деятельности.
Интересно, что процессоры/контроллеры MISC архитектуры почти не представлены на рынке как вычислительные устройства при всех их имеющихся достоинствах и недостатках, да и опубликованных работ в этом направлении не так много представлено в i-net сети.
Может быть этому какое то объяснение, кроме того, что RISC архитектура «круче»?vkni Автор
23.02.2022 09:42На странице курса некоторые ссылки на дополнительные материалы не валидны.
Только ссылка на "решение", ну и ладно. Не, там косяков масса, но, в основном, в теле книги — она явно не "книжного качества". :-(
А, не рассматривалась ли возможность, к примеру, включения в курс и темы применения конкатенативных языков Форт направленности (Factor) на каком то «уровне» курса/примеров?
Конкретно в этот? Наверняка — нет, оно там ни к чему. И так объём весьма и весьма велик.
Серебрякова посмотрю, спасибо.
forthuser
23.02.2022 09:53* был же к примеру процессор PSC1000 его описание
P.S. В тему дополнительные проекты/материалы для компилятор построения.
libFirm — A graph based SSA intermediate representation
firmforth is a just-in-time-compiling forth-like system using libfirm.It is small and comprehensible and was written to evaluate JIT-compilation using libfirm.
LibFirm on Github
dbezheckov
23.02.2022 11:26+1Как-то тема не соответствует посту. При чем тут Nanopass?
"в рамках Nanopass компилятор — это некоторое последовательное применение процедур-проходов к тексту программы..."
Почти все современные компиляторы и компиляторные инфраструктуры так и устроены, при чем тут именно Nanopass? Смысл слова "Nano" не раскрыт. Скорее статью стоило бы назвать - "как я проходил курс университета xxx по компиляторам".
Но в целом круто, примите мой плюс в карму.
vkni Автор
23.02.2022 18:33Спасибо, но позволю себе несогласиться, в конце-концов это же я выбирал название. :-)
Nanopass — это "человек и пароход": определённый фреймворк (которым я не пользовался, но, видимо, надо будет увидеть его в деле), а кроме того подход чёткого разделения этих проходов с явно прописанными интерфейсами. Прошу прощения, что недостаточно чётко это сформулировал.
То есть, на вход каждого прохода попадает программа на языке с чётко определённой семантикой (для него есть тестовый интерпретатор!), а после каждого прохода получается программа на, как правило, другом, но не менее чётко определённом языке со своим интерпретатором. То есть, вы можете "запустить" программу после любого прохода и сравнить её результат с интерпретацией изначальной программы.Нет, не каждый компилятор устроен по архитектуре Nanopass. К примеру, знакомый мне Ocaml хоть и содержит проходы, они, как правило, комплексные. Внутренние языки компилятора Ocaml не имеют опубликованной чётко определённой семантики. И хотя есть механизмы печати внутренних представлений, нет никаких интерпретаторов.
Название статьи двойное, вторая часть как раз о курсе. Здесь, на Хабре термин nanopass упоминается впервые, поэтому если вы можете рассказать по этой теме ещё, напишите, пожалуйста, заметку.
vkni Автор
Забыл добавить — особенных требований к знанию языка Racket не требуется. То есть, достаточно пройти первую треть SICP или https://htdp.org или ещё что-то подобное. Для Racket написаны хорошие руководства и cheat sheet — https://docs.racket-lang.org/racket-cheat/index.html , по которым легко определяется то, что нужно.
Для алгоритмики достаточно базового набора функциональщика — map, filter, filterMap, fold(left/right) + базовый набор "питониста" — hash-map, dictionary + начальные сведения по графам.