Палеокомпьютинг, часть 1: процессор Вирта, проект Оберон, новая архитектура в Qemu и KubeVirt и запуск в Cozystack и K8s

1 января 2024 года умер Никлаус Вирт, человек, который придумал Pascal, Modula-2 и Oberon, получил премию Тьюринга и всю жизнь упрямо воевал с раздутым софтом. Мало кто знает, что уже глубоко за семьдесят он сел и спроектировал собственный процессор, маленький и простой, чтобы на нём можно было показать студентам компьютер целиком, от логических вентилей до окон на экране.
В сентябре 2026 года я взял этот процессор и попробовал запустить его везде, куда смог дотянуться. Сначала прямо во вкладке браузера, причём не в эмуляторе, а в виде той самой схемы, которую нарисовал Вирт. Потом в QEMU, как обычную виртуальную машину. Потом в Kubernetes, где вообще-то живут совсем другие виртуалки, с Ubuntu и базами данных. И в конце концов в Cozystack, нашей облачной платформе, где машину Вирта теперь можно поставить кнопкой из каталога, как какой-нибудь PostgreSQL. По дороге я наконец измерил то, о чём программисты спорят десятилетиями, — сколько на самом деле стоит проверка выхода за границы массива. Ещё запустил на процессоре Вирта маленькую языковую модель. И, раз уж честно, одним неосторожным движением отправил в переезд все виртуалки рабочего кластера (никто не пострадал, но было неприятно).
Статья получилась очень длинной, потому что за девять дней случилось очень много всего, и значительная часть случившегося — это мои собственные ошибки, которые пришлось находить и исправлять. Я старался писать так, чтобы её мог читать человек, который про Оберон никогда не слышал, а всё, что интересно только специалистам, спрятал под спойлеры. Читать можно подряд, а можно прыгнуть сразу в интересную часть. Сначала будет рассказ про сам Оберон, потом про то, как разваливался мой первоначальный план, потом про процессор и проверку границ, про браузер и лабораторные, про QEMU, Kubernetes и Cozystack, а в самом конце про языковую модель и про то, как всё это поставить себе.
Все исходники, заметки и инструкции лежат в репозитории github.com/tym83/paleocomputing, а сайт проекта — tym83.github.io/paleocomputing. Если хочется сначала потрогать руками, а потом читать, откройте лабораторию: ставить ничего не нужно, машина запустится прямо в браузере.
Что такое Оберон
Oberon — это одновременно язык программирования и операционная система, которые в середине восьмидесятых сделали в ETH Zurich Никлаус Вирт и Юрг Гуткнехт. Начали осенью 1985 года, а к 1988-му система уже работала по-настоящему. Писали её, по словам самого Вирта, два человека в свободное от основной работы время, и это, согласитесь, звучит довольно дико, если вспомнить, сколько людей сегодня пишут любую операционную систему. Работала она на рабочей станции Ceres, которую тоже сделали в ETH, и студентов на ней учили примерно до начала двухтысячных.
Название, кстати, пришло от «Вояджера». В январе 1986 года он прислал снимки Урана и его спутников, и Вирт, который считал «Вояджер» образцовым инженерным проектом (аппарат работал далеко за пределами расчётного срока), назвал систему в честь спутника Оберон. В книге он называет Оберон крупнейшим спутником Урана, хотя на самом деле больше Титания, — даже у Вирта бывают опечатки:) Ну и заодно Оберон — это король эльфов, что тоже неплохо.
Главная идея всей затеи сформулирована в предисловии к книге про проект. Систему, сделанную с нуля, должно быть можно описать, объяснить и понять целиком, то есть один человек должен быть способен прочитать и понять её целиком, от процессора до оконного интерфейса. По сегодняшним меркам это звучит почти как фантастика, потому что современную ОС вместе с её компилятором и процессором целиком не понимает никто на свете.
В 2013 году Вирт выпустил новую редакцию проекта, и в ней он довёл эту идею до конца. Процессор, на котором работала Ceres, давно сняли с производства, и Вирт решил, что раз уж всё остальное в системе своё и понятное, то и процессор должен быть своим. Он спроектировал простой процессор RISC (в исходниках он называется RISC5) и описал его на Verilog, языке, которым описывают цифровые схемы. Такую схему можно «залить» в ПЛИС, программируемую микросхему, и получить настоящий работающий компьютер. У Вирта он работал на недорогой плате с одним мегабайтом памяти на частоте 25 МГц. Это примерно в сто раз медленнее одного ядра вашего телефона, но Оберону этого хватает с запасом, потому что компилятор, как пишет Вирт, собирает сам себя примерно за три секунды. Книга и исходники системы, компилятора и процессора лежат в открытом доступе, и именно это делает Оберон уникальным. И ещё одна деталь, которая нам пригодится. Сама система родом из конца восьмидесятых, а процессор, на котором мы будем её запускать, появился уже в 2010-х.
Чем Оберон так необычен, если вы его никогда не видели
Любой текст может быть командой. В Обероне нет командной строки в привычном смысле. Если где-то на экране, в любом окне, написано что-нибудь вида Модуль.Процедура, это уже команда. Вы наводите на неё мышь, нажимаете среднюю кнопку, и она выполняется. Меню здесь тоже есть, но это просто строчка текста в заголовке окна с перечисленными командами. Хотите своё меню — напишите нужные команды в текстовый файл и откройте его. Эта идея потом сильно повлияла на редактор Acme из Plan 9, и Роб Пайк прямо об этом писал.
Нужна мышь с тремя кнопками. Левая ставит курсор, средняя исполняет, правая выделяет, а ещё есть комбинации, когда вы нажали одну кнопку и, не отпуская её, нажали вторую. На ноутбуке без средней кнопки мы её изображаем щелчками с клавишами-модификаторами, об этом будет ниже.
Программа всегда одна. Привычной многозадачности нет. Внизу крутится один цикл, который опрашивает клавиатуру и мышь и вызывает команды по очереди, и пока команда не закончилась, ничего другого не происходит. Вирт сам признаёт, что звучит это очень ограничивающе, и подробно объясняет, почему для одного человека за одним компьютером этого достаточно.
Нет заголовочных файлов и нет ада библиотек. Каждый модуль сам описывает свой интерфейс, и у этого описания есть что-то вроде контрольной суммы. Если интерфейс поменялся, система просто откажется запускать модули, собранные против старой версии, так что программа, собранная со старой библиотекой и падающая непонятно где, здесь невозможна в принципе. И это в 1988 году.
Защиты памяти нет, её заменяет язык. В процессоре нет механизмов, которые не дают одной программе залезть в память другой. Вся безопасность держится на том, что язык строго типизирован и сам убирает мусор, а компилятор проверяет каждое обращение к массиву. Мы это ещё сломаем в лабораторных.
Язык маленький. Полное описание Oberon-07 занимает 17 страниц, а эпиграфом к нему стоит фраза Эйнштейна о том, что всё нужно делать настолько простым, насколько возможно, но не проще.
Зачем в 2026 году возиться с музейным экспонатом? У меня на это три причины. Первая из них в том, как сильно Оберон повлиял на то, чем мы пользуемся сегодня. Язык Go, на котором сегодня написана половина облачной инфраструктуры, включая Kubernetes, прямо называет Оберон одним из предков, в Go FAQ так и написано. А Роберт Гриземер, один из трёх авторов Go, защищал диссертацию в ETH у Вирта и в докладе «The Evolution of Go» рисует генеалогическое дерево, где от Оберона идёт прямая линия к Go. Вторая причина в том, что Оберон действительно помещается в голову. Если хочется понять, как устроен компьютер от вентиля до окна, лучшего учебного пособия я не знаю. И третья причина в том, что в 1995 году Вирт написал статью «A Plea for Lean Software», манифест против раздутого софта (оттуда пошёл закон Вирта о том, что программы замедляются быстрее, чем ускоряется железо, хотя сам Вирт честно приписывал это наблюдение коллеге Мартину Райзеру), и Оберон был в ней доказательством того, что можно жить иначе. С тех пор прошло тридцать лет, и стало, мягко говоря, только хуже.
И Оберон, что приятно, до сих пор жив. У него есть активные форки, которые обновлялись буквально на днях, и русскоязычное сообщество OberonCore, которое бодро обсуждает всё подряд.
Что почитать и посмотреть про Оберон (лучшее, что я нашёл)
Project Oberon, издание 2013 года — главный первоисточник: книга про систему, компилятор и процессор и все исходники к ней.
projectoberon.net — сайт Пола Рида, самый удобный вход: зеркала, архивы, готовый образ диска.
The Programming Language Oberon (Oberon-07) — весь язык на 17 страницах, читается за вечер.
Wirth, «Modula-2 and Oberon» — история из первых уст: что Вирт взял у Xerox PARC и что сознательно выбросил. Моя любимая цитата оттуда: «We were inspired by what could be done, and shown how not to do it».
Wirth, «A Plea for Lean Software» — тот самый манифест 1995 года.
Wirth, «Compiler Construction» — короткий и очень понятный учебник по компиляторам на примере урезанного Оберона под тот же процессор.
The RISC Architecture — описание процессора на нескольких страницах.
Видеоинтервью ETH с Виртом, 2021 — по ссылке первая из трёх частей.
Griesemer, «The Evolution of Go» и слайды — мост от Оберона к Go.
Эмуляторы: oberon-risc-emu Питера Де Ваххтера, OberonEmulator Михаэля Ширля (работает в браузере), Norebo — компилятор Оберона, который запускается из обычной командной строки, без него половины этой статьи не было бы, и Extended Oberon Андреаса Пирклбауэра.
По-русски: библиотека OberonCore с переводами Вирта, форум, Информатика-21 и книга «Разработка операционной системы и компилятора. Проект Оберон», вышедшая в ДМК Пресс в 2012 году. Судя по году, это перевод издания до 2013 года, то есть ещё без процессора Вирта, но сам я не сверял, поправьте, если ошибаюсь.
Откуда взялась эта затея
У меня давно копился список забытых систем. Это операционки, языки и целые машины, которые когда-то работали, иногда очень хорошо, а потом исчезли, причём часто вместе с идеями, которые с тех пор так никто толком и не повторил. Burroughs B5000, который ещё в 1961 году проверял типы данных прямо в железе. KeyKOS, для которой выдернутый из розетки шнур был штатной ситуацией, а не катастрофой. Транспьютеры, Lilith, iAPX 432. Про всё это написаны статьи и книги, но почти никто это не запускает, а мне хотелось именно запустить, по-настоящему, со всеми потрохами, и посмотреть, что оно умеет.
Так появилась серия, которую я назвал Палеокомпьютингом. Идея у неё простая. Многие из этих систем проиграли не потому, что были плохими, а потому, что были дорогими. Своё железо под свой язык стоило безумных денег, а обычные процессоры Intel дешевели быстрее, чем кто-либо успевал сделать что-нибудь умное. Сейчас экономика другая. Программируемые микросхемы стоят копейки, открытая архитектура RISC-V официально разрешает добавлять в процессор свои команды, а проект CHERI (про него будет много ниже) по сути возвращает в современные процессоры ту самую аппаратную защиту памяти, которую Burroughs делал ещё до моего рождения. То есть старые идеи снова можно проверять руками, чем я и занялся.
Первой серией я выбрал Оберон, хотя Burroughs хотелось больше. Причина простая, у Оберона есть абсолютно всё, что нужно для работы. Исходники процессора, компилятор, операционная система, книга, которая всё это объясняет, живые эмуляторы и готовые образы дисков. Можно сразу нырять на полную глубину и не тратить месяц на раскопки документации, а для старта серии мне как раз нужен был масштаб.
Как это делалось
Сразу признаюсь, что большую часть кода в этом проекте писал не я, а нейросеть. Точнее, несколько нейросетевых агентов, которым я раздавал разные роли. Одни писали код, другие его проверяли, а третьи получали от меня задание найти, почему всё это не работает, и относились к нему очень серьёзно. Ниже в тексте они будут появляться как рецензенты и аудиторы, и я хочу, чтобы с самого начала было понятно, что это не живые люди, а те же нейросети, которым я поручил играть придирчивого читателя, специалиста по железу или методолога измерений. Работали они независимо друг от друга и от того агента, который писал код, и это, пожалуй, оказалось самым полезным изобретением всего проекта. На мне оставались замысел, решения, бесконечные вопросы, точно ли всё проверено, и живой кластер, который, как выяснится, очень легко расстроить.
Я говорю об этом не ради модной темы, а потому, что без этого невозможно понять всю остальную историю. Во-первых, всё, что здесь описано, сделано за девять дней, с 21 по 29 сентября, и такой темп был бы невозможен, пиши я всё руками. Во-вторых, из-за этого часть кода нельзя отдать в большие открытые проекты, об этом будет в разделе про QEMU. И в-третьих, у нейросети есть одна очень характерная привычка, она очень любит сообщать, что всё готово и все проверки зелёные. На практике это часто означало, что проверка просто ничего не проверяла. Так что большая часть этих девяти дней ушла вовсе не на то, чтобы написать код, а на то, чтобы проверки научились честно краснеть, когда что-то сломано, и эта история будет проходить через всю статью.
План, который развалился за один день
Первоначальный план звучал очень красиво. Настоящий процессор Вирта должен был работать прямо во вкладке браузера, такт за тактом, причём не в виде эмулятора, написанного по документации, а в виде его собственной схемы. Читатель мог бы поменять в этом процессоре систему команд прямо на странице, нажать кнопку и через несколько секунд увидеть, как операционная система продолжает работать на изменённом железе. А сверху я собирался положить три громких утверждения. Что весь компьютер, от вентилей до окон, работает в браузере. Что если добавить в процессор специальную команду, которая умножает и складывает за один раз, то маленькая нейросеть на нём заработает минимум вдвое быстрее, и сделать это можно буквально за минуту. И что если добавить в процессор аппаратную проверку границ массивов, то можно впервые честно измерить, сколько такая проверка стоит, потому что, как я самоуверенно написал в черновике, такого числа нет ни у кого.
Срок я себе поставил от двух до четырёх недель. Ага.
Прежде чем садиться писать код, я отдал этот план на разбор пятерым рецензентам, и каждому досталась своя область, то есть сроки, железо, компилятор, методика измерений и браузер. Задание было одно на всех: найдите, почему это не сработает. Замечаний набралось на восемьдесят с лишним килобайт текста, и после них от плана осталось немного.
Самым обидным оказалось то, что место для новых команд в процессоре, которое я, как мне казалось, нашёл, было вовсе не свободным. Я увидел в описании команд бит, который всегда равен нулю, и собирался посадить туда свои новые команды. Рецензент по железу открыл схему Вирта и показал, что процессор этот бит просто не смотрит, так что все мои новые команды на обычном процессоре молча выполнялись бы как самая частая команда пересылки данных, и программа тихо делала бы совсем не то, что написано. Свободного места в системе команд не оказалось вовсе. Позже, правда, нашлось одно небольшое место, куда компилятор Вирта всегда пишет нули, и о том, как я пытался туда втиснуться, будет отдельная история.
Ускорение нейросети вдвое тоже не пережило разбора. Рецензенты посчитали прямо по схеме, сколько тактов уходит на каждую операцию, и оказалось, что даже если новая команда будет совершенно бесплатной, ускорить программу больше чем в два раза она не может в принципе, а реально сделанная — хорошо если на несколько процентов. И тут же прозвучало главное, что потом подтвердилось. Тормозит вовсе не отсутствие новой команды, а то, что умножитель у Вирта очень медленный, он считает произведение по одному биту за такт.
С числом, которого якобы нет ни у кого, вышло совсем неловко. Методолог принёс список работ, где цену аппаратной защиты памяти измеряли многократно, в том числе на современных процессорах Arm и в проекте CHERI. Относительно новым оказалось разве что то, что в качестве нагрузки я хотел взять систему, которая пересобирает сама себя, но и это скорее деталь, чем открытие.
А ещё выяснилось, что в Обероне проверку границ массивов в обычном коде вообще нельзя выключить, она встроена в компилятор намертво. Сравнивать систему Вирта с системой без проверок было просто не с чем, и пришлось с самого начала планировать собственный патч компилятора, который делает версию без проверок.
Что ещё разнесли рецензенты
Я собирался указывать результаты с доверительным интервалом, как положено в серьёзной статистике. Методолог заметил, что на симуляторе это бессмысленно, потому что один и тот же прогон всегда даёт одно и то же число, до такта, так что никакого разброса там нет и интервалу неоткуда взяться. Разброс появляется, только если брать много разных программ, и мерить надо его.
Ещё рецензенты заметили, что видеоконтроллер на настоящей машине отбирает у процессора около 7% времени, потому что постоянно читает память для экрана, и если его не учитывать, все числа будут немного оптимистичнее реальности. Что два умножения подряд у Вирта стоят дороже, чем два умножения по отдельности. Что стандартный открытый инструмент для оценки площади схемы по умолчанию теряет больше половины элементов памяти. И что у всего плана нет ни одной промежуточной точки, где можно остановиться и что-то показать людям, а для проекта, который делается в свободное время, это смертельно.
Итоговая оценка сроков после ревью — от девяти до тринадцати недель, если работать по вечерам. Мы уложились в девять дней, но только благодаря тому, что код писали агенты, и об этом я уже говорил.
После разбора я переписал план. В первой серии осталось два утверждения. Первое о том, что весь компьютер работает в браузере, а второе о том, что можно честно измерить цену проверки границ на полностью открытой системе, где видно всё — от схемы процессора до компилятора. Нейросеть и новую команду для неё я вынес в отдельную серию. А в таблице рисков появилась строчка, которую я очень люблю. Там написано, что число, скорее всего, выйдет скучным, процентов шесть, и что это и есть результат.
Забегая вперёд, нейросеть на процессоре Вирта я всё-таки запустил, и рецензенты там оказались правы в главном, всё упиралось в умножитель. Но это ближе к концу.
Запускаем настоящий процессор Вирта
Начинается всё с простого. Вирт описал свой процессор на Verilog, и это описание не программа, а схема, в которой есть регистры, провода и логика между ними. Чтобы такую схему выполнить без микросхемы, есть инструмент Verilator, который превращает её в программу на C++, и эта программа честно пересчитывает на каждом такте каждый провод процессора. Работает это медленнее настоящего железа, но зато в точности как оно, без всяких приближений. К процессору нужно ещё приделать экран, диск, клавиатуру и мышь. Эту обвязку я сделал сам, повторив ровно тот интерфейс, который ожидает система, а сам процессор Вирта не трогал ни строчкой. Для меня это было принципиально, потому что всё, что я буду дальше мерить, должно измеряться на его схеме, а не на моей.
Первый рабочий день начался 22 сентября в час ночи, а к обеду настоящая схема Вирта загрузила настоящий Project Oberon. На загрузку уходит около двенадцати миллионов команд, и на моём ноутбуке симуляция справляется с этим за четыре секунды. Картинка в начале статьи снята именно с этого запуска.
На чём загрузка висела до обеда
Сначала я взял не тот загрузчик. В репозитории Вирта лежит загрузчик, который получает систему по последовательному порту, а с диска не читает вообще, и так как начало у него такое же, как у нужного, я довольно долго смотрел на правильный на вид код.
Потом процессор выполнял первой командой ерунду. Пока процессор сбрасывается, он всё равно читает команду с шины данных, и если там в этот момент нули, первой выполнится бессмысленная пересылка вместо перехода на загрузчик, и дальше машина стоит. На эту же ошибку я потом наступил ещё трижды в разных тестовых стендах, и один раз она очень хорошо пряталась, об этом ниже.
А третьим был экран вверх ногами, потому что видеоконтроллер Вирта читает изображение из памяти снизу вверх. Рецензент по железу это предсказал заранее, и предупреждение сэкономило мне кучу времени.
Запустить — это ещё полдела, надо убедиться, что запустилось правильно. Для этого есть приём, который называется пошаговой сверкой. Вы берёте эталон, которому доверяете, и гоните его вместе со своей машиной по одной и той же программе, команда за командой, после каждой команды сравнивая содержимое всех регистров. Если хоть один бит где-то разошёлся, вы узнаёте об этом сразу и знаете, на какой именно команде. В качестве эталона я взял эмулятор из проекта Norebo, и на всей загрузке системы, почти пятнадцати миллионах команд, моя схема ни разу с ним не разошлась. Правда, сначала расхождение было, и виноват в нём был я, потому что по совету одного из рецензентов на всякий случай записал в память служебное значение, которое эмулятор пишет только в определённой ситуации, а в этом запуске она не возникала. Правильно было не трогать ничего.
Тут надо сказать, почему это вообще так важно. Все числа тактов в этой статье считает отдельная быстрая модель, потому что гонять настоящую схему на больших нагрузках слишком долго. И этой модели можно верить ровно настолько, насколько она совпадает со схемой. Я сверил их на той же загрузке и получил полное совпадение до такта, но сначала сверка показала, что модель врёт почти на шестнадцать процентов, и я уже почти поверил в это. Оказалось, что врала сама сверка, потому что заглядывала в адрес команды на такт раньше, чем нужно. Если бы я тогда опубликовал, что модель ошибается на 16%, это обесценило бы вообще все числа в проекте. С тех пор у меня правило, что отрицательный результат нужно проверять так же тщательно, как положительный, потому что ошибиться можно в обе стороны.
Сколько стоит проверка границ массивов
Теперь главный вопрос первой серии. Если вы пишете на C a[i], а i оказалось больше длины массива, программа молча прочитает или запишет чужую память. Из этой простой ошибки выросла огромная доля уязвимостей последних пятидесяти лет, от переполнений буфера в девяностых до сегодняшних отчётов о безопасности браузеров. Защититься от неё очень просто. Перед каждым обращением к массиву сравнить индекс с длиной и, если он вылез за границу, остановить программу. Многие языки так и делают, Оберон делает это всегда, а в C и C++ проверку традиционно не пишут, потому что считается, что это медленно. Вот я и хотел узнать, насколько медленно на самом деле, на системе, где видно абсолютно всё.
Для этого нужны три варианта одной и той же системы. Первый вариант вообще без проверок, и такого Оберона не существует, так что я сделал его сам, поменяв одну строчку в компиляторе. Второй с программной проверкой, как у Вирта, где компилятор перед каждым обращением вставляет сравнение и переход на обработчик ошибки. А третий с аппаратной проверкой, где процессор получает новую команду, которая делает всё это сама за один такт. Про эту команду будет отдельный раздел. В качестве нагрузки я взял компилятор Оберона, который компилирует несколько модулей самой системы, потому что это настоящая большая программа, а не синтетический тест.
Первое число, которое у меня получилось, было меньше половины процента, и я очень обрадовался, что проверки почти ничего не стоят. А потом понял, что сравнивал не то. Я запускал два разных компилятора, с проверками и без, и компилятор без проверок просто делал меньше работы, потому что ему не нужно было вставлять проверки в чужой код. А сравнивать надо одинаковую работу, то есть собрать один и тот же компилятор в двух вариантах и дать им одну и ту же задачу. После того как я это исправил, число выросло в несколько раз.
Дальше было ещё несколько заходов, и честный ответ в итоге получился такой. Пока проверки убраны только из самого компилятора, они стоят около двух процентов времени. А если убрать их из всей системы, на которой компилятор работает, включая работу с текстами, файлами и памятью, выходит почти пять процентов. То есть на каждые сто тактов работы компилятора от двух до пяти уходят на то, чтобы программа никогда не вылезла за границы массива. Много это или мало, каждый решит сам, но на мой вкус это очень дёшево за отсутствие целого класса уязвимостей. С одной оговоркой, которую рецензенты сделали ещё в первый день. Нагрузка у меня одна, так что это число про компилятор Оберона, а не про любые программы на свете.
Тут меня ждал первый сюрприз. Когда я пересчитал, какие именно проверки стоят в коде, оказалось, что проверок границ массивов в Обероне совсем немного, а подавляющее большинство составляют проверки того, что указатель не пустой, то есть не равен NIL. В самом компиляторе их больше трёхсот против единиц проверок индексов. Так что цена безопасности в Обероне складывается в основном из того, что программа не может обратиться по пустому указателю, а собственно массивы тут дело второе.
Как меня поймали на пересказе чужих статей
Сначала я сравнил свои проценты с опубликованными работами о защите памяти на других процессорах и получил очень красивую табличку. Аудитор отозвал её целиком, потому что три числа из четырёх я пересказал неправильно. Где-то я взял цифру для облегчённого режима защиты, а полная стоила в пять раз дороже. Где-то основную часть потерь давали промахи кэша, а у процессора Вирта кэша нет вообще, так что сравнивать это с нашим числом бессмысленно. Где-то два разных режима работы я принял за диапазон значений. А в одной статье результат был указан в разах, а я прочитал его как проценты, и замедление в полтора-два с половиной раза превратилось у меня в замедление на полтора-два с половиной процента. Больше всего мне стыдно именно за это.
Ближе всего к моей постановке оказалась диссертация, где на нескольких процессорах CHERI получилось 10–16%, но меряется там полная защита указателей, то есть вещь заметно более широкая, так что это скорее ориентир, чем сравнение. А фраза о том, что такого числа нет ни у кого, не пережила и этого, потому что ещё в 1981 году вышла статья, где мерили цену проверок в Паскале. Мерили, конечно. Корректно говорить только о том, что мы чего-то не нашли в таких-то источниках, и с тех пор я говорю только так.
Как я учил процессор проверять самому
Теперь аппаратная проверка. Идея очень простая. Вместо двух команд, сравнения и условного перехода, в процессоре появляется одна новая команда, которую я назвал CHK. Она получает индекс и длину массива и, если индекс вылез за пределы, сама переходит на обработчик ошибки. Всё это за один такт вместо двух.
Сложность оказалась в том, куда в команду положить длину массива. Команда у процессора Вирта занимает 32 бита, и почти все они уже заняты. Единственное место, которое, как я доказал перебором всех возможных значений, процессор действительно не использует, — это двенадцать бит в середине команды. Двенадцать бит — это массивы длиной до четырёх тысяч элементов, в Обероне таких подавляющее большинство, так что я обрадовался и положил длину туда.
И получил очень неприятный результат. Когда в Обероне срабатывает проверка, система сообщает, где и какая именно ошибка случилась, и номер ошибки берёт из тех же самых бит команды, куда я положил длину. Я поставил эксперимент с настоящей ошибкой, программой, которая лезет за границу массива из ста элементов. Обычная система честно написала, что индекс вышел за пределы массива. А моя, с новой командой, так же уверенно написала, что было обращение по пустому указателю, потому что кусок числа 100 попал в поле номера ошибки. Программист, получив такое сообщение, пошёл бы искать несуществующую ошибку с указателями и потратил бы на это полдня. Это хуже, чем если бы система не сказала вообще ничего.
Дальше я пару раз пытался выкрутиться, уменьшив длину до восьми бит, и оба раза неудачно. Сначала статистика показала, что восьми бит хватает большинству массивов, но потом выяснилось, что большинство в этой статистике — одинаковые буферы для имён файлов, которые почти не используются, а горячие циклы, которые выполняются миллионы раз, как раз работают с большими массивами и под восемь бит не влезают. Решение нашлось в другом месте. Оказалось, что новой команде не нужен регистр для результата, потому что она ничего не вычисляет, а только проверяет, и четыре бита, которые обычно указывают этот регистр, свободны. Длину можно разрезать на два куска, четыре старших бита положить туда, а восемь младших — в середину команды, так, чтобы они не задевали номер ошибки. Получились и двенадцать бит длины, и правильные сообщения об ошибках.
Как выглядит новая команда в схеме процессора и какие ловушки в ней нашлись
Вся правка процессора сидит под одним переключателем, так что можно собрать ровно одну и ту же схему с новой командой и без неё и сравнивать их между собой (здесь упрощённо, без сборки длины из двух кусков):
`ifdef WITH_CHK
assign CHK = ~p & ~q & ~u & v & (op == 1);
assign chkFail = CHK & (B >= chkLim);
`else
assign CHK = 1'b0;
assign chkFail = 1'b0;
`endif
Сначала в первой строке не было ~u, и из-за этого новая команда занимала две кодировки вместо одной, то есть заодно перехватывала ещё одну, чужую. Нашла это проверка, которая перебирает все возможные команды на обычном процессоре и на процессоре с новой командой и ищет, где они ведут себя по-разному. Правильный ответ — ровно в одном месте, а нашлось два.
А ещё аудитор нашёл, что будет, если модуль с новой командой случайно запустить на обычном процессоре. Обычный процессор примет её за сдвиг и молча испортит один из регистров, причём какой именно, зависит от длины массива, и при длине около четырёх тысяч это регистр, где хранится адрес возврата из процедуры. Со стороны это выглядело бы как необъяснимая порча стека. Теперь такие модули помечаются новой версией формата, и обычная система просто отказывается их загружать. Смешно, что требование пометить версию было в самом первом ревью, я его принял и потерял.
Сколько же в итоге экономит аппаратная проверка? На компиляторе она снимает примерно шестую часть цены проверок, на смешанной нагрузке с массивами разных размеров — десятую, а на чисто вычислительном коде с небольшими массивами — половину. Половина, кстати, — это потолок, и больше не будет никогда, потому что две команды превратились в одну. Разница между нагрузками объясняется очень просто. Команда помогает только там, где длина массива помещается в двенадцать бит. Где массив больше, компилятор ставит старую программную проверку, и выигрыша нет.
И ещё одна прекрасная история из этого раздела. Для второй нагрузки я написал небольшую программу с сортировкой и умножением матриц. В варианте с проверками она сразу упала с сообщением о выходе за границу массива, и оказалось, что ошибка у меня в самой программе, потому что я посчитал индекс неправильно и вылезал за массив в три с половиной раза. А вариант без проверок отработал молча, как будто всё в порядке, и тихо записал две с половиной тысячи чисел в чужую память. Ошибку нашли только потому, что проверки были включены, и лучшей рекламы проверкам границ я, честно говоря, не придумаю.
Сколько новая команда стоит в железе, то есть сколько места она занимает на кристалле и не замедляет ли она процессор, я тоже измерил, и ответ получился скучным. Она добавляет меньше процента логики, а частоту не трогает вообще. Самое интересное здесь то, как я к этому скучному ответу пришёл, потому что по дороге выяснилось, что шум в таких измерениях больше самого эффекта.
Как шум оказался больше самого эффекта
Площадь схемы оценивают открытыми инструментами синтеза, которые превращают описание на Verilog в набор логических элементов. Первая ловушка была в том, что инструмент в режиме по умолчанию молча не учитывал три четверти элементов памяти, и единственным намёком на это был плюсик в самом конце одной строки отчёта. Вторая в том, что без явно заданных ограничений он просто игнорировал цель по скорости, и схема для 200 пикосекунд и для 50 наносекунд выходила одинаковой до последнего знака.
А хуже всего было другое. Я взял схему Вирта и четыре раза переписал её так, чтобы логика вообще не менялась, например, поставил лишние скобки или добавил бессмысленное ИЛИ с нулём. Площадь после этого гуляла примерно на столько же, сколько добавляет моя новая команда. То есть эффект, который я хотел измерить, был того же размера, что и разница от того, как записано совершенно не тронутое выражение. Так что честно можно сказать только, что это меньше процента, а что касается частоты, то один способ сборки показывал чуть быстрее, другой чуть медленнее, и выбрать любой из них значило бы выбрать удобный ответ, а не измерить.
Потом я развёл схему на настоящей ПЛИС, то есть попросил инструменты разложить её по реальным ячейкам конкретной микросхемы и проложить между ними провода. Только после этого видно, на какой частоте схема на самом деле сможет работать. Оказалось, что родные 25 МГц держатся с большим запасом, новая команда на частоту не влияет, а бо́льшая часть задержки в схеме приходится на провода, а не на логику.
Аудиторы не приняли работу
К вечеру первого дня у меня было много красивых чисел, и я отдал их на приёмку пяти аудиторам. В 17:40 они отчитались, и все пятеро написали одно и то же слово, НЕ ПРИНИМАЮ, большими буквами.
Самым неприятным и одновременно самым полезным оказался мутационный аудит. Идея у него простая и очень жестокая. Аудитор нарочно вносит в процессор правдоподобные ошибки, например, меняет в сравнении «больше или равно» на «больше», и смотрит, заметят ли это мои тесты. Если тесты остаются зелёными на сломанном процессоре, значит, они ничего не проверяют. Из тридцати внесённых ошибок мои тесты поймали десять. Две трети сломанных процессоров прошли все проверки как исправные.
Причины оказались очень поучительными. В одном месте проверка, которая не смогла выполниться, считалась пройденной, и отчёт гордо сообщал, что проверено три случая из пяти и ошибок нет. В другом сборка тестов была настроена так, что её провал не останавливал запуск, а успех определялся по смайлику в последней строке вывода, так что ноль выполненных тестов давал полностью зелёный отчёт. А команда, которая якобы проверяла загрузку системы, на самом деле вообще ничего не проверяла. С процессором, у которого была сломана одна из команд, машина зависала в самом начале загрузки, а проверка всё равно рапортовала успех. К тому же в загрузке системы нет ни одной операции с дробными числами, так что моё утверждение, будто сверка проверила всю арифметику процессора, было просто неправдой.
Главный вывод аудиторов был в том, что почти все ошибки случились при переносе результатов в текст. Число, полученное в одних условиях, переезжало в сводку как общее. Мы переписали всё, что нашли, добавили такие же намеренные поломки в автоматическую проверку на каждое изменение и ввели правило, что любая проверка должна уметь провалиться, и это нужно показать. А на следующий день нашлась ещё одна прелесть. Во всех двухстах с лишним тестах процессора самая первая команда программы на самом деле не выполнялась, из-за той самой ошибки с шиной во время сброса, про которую я писал выше. Заметить это было невозможно, потому что каждый тест начинался с команды, результат которой всё равно ни на что не влиял. Самое обидное, что эту ошибку я уже находил и исправлял в другом стенде, и даже оставил там комментарий о том, что ровно на этом я и споткнулся.
Мелкие радости первых дней
Я написал скрипт, который ждёт, пока закончится долгий прогон, и проверяет это по имени процесса. Скрипт ждал полтора часа, потому что нашёл сам себя, ведь имя процесса было написано в его собственной командной строке. Очень по-дзенски, а я всё это время думал, что идёт долгая компиляция.
Четыре миллиарда команд ушли впустую, потому что эмулятор и процессор по-разному записывают адреса устройств, и проверка, обращается ли программа к устройству, ни разу не сработала.
Одно и то же поле в команде перехода компилятор Вирта считает 24-битным, дизассемблер — 20-битным, а сам процессор — 22-битным. Три части одной системы, написанные одним человеком, и каждая по-своему права.
Одна из команд для подготовки тестов молча портила эталонный образ диска прямо в репозитории, а проверка экрана на испорченном образе всё равно совпадала. Теперь всё работает на копиях, а у эталона лежат контрольные суммы.
Два умножения подряд у Вирта стоят почти в полтора раза дороже, чем два умножения по отдельности, потому что умножитель не успевает сбросить свой счётчик. Я сначала обрадовался находке, а потом узнал, что медленность умножителя на форумах Оберона обсуждали ещё в 2016 году. Именно этого механизма я там не нашёл, но то, что я чего-то не нашёл, ещё не значит, что никто этого не знал.
А в 19:30 первого дня круг замкнулся. Компилятор Оберона, работающий на настоящей схеме Вирта, скомпилировал сам себя, и результат совпал байт в байт с тем, что даёт эмулятор. На следующий день то же самое получилось уже внутри самой системы, с окнами и мышью. Скрипт из щелчков и нажатий клавиш заставил систему пересобрать себя целиком, и все файлы вышли точно такими же, как в оригинальном образе диска. Попутно выяснилось, что официальный образ системы 2016 года сам с собой не вполне согласован: пара модулей в нём устарела, а одного не было вовсе. Обычная жизнь любого живого проекта, и меня она, честно говоря, растрогала.
Оберон во вкладке браузера
Теперь первое утверждение серии, про браузер. Схему Вирта, которую Verilator превратил в программу на C++, можно дальше скомпилировать в WebAssembly, то есть в формат, в котором браузер умеет исполнять обычный скомпилированный код почти с родной скоростью. И тогда во вкладке крутится не эмулятор, написанный кем-то по документации, а та же самая схема, которую Вирт заливал в свою ПЛИС, со всеми её проводами и тактами. Оговорюсь, что экран, диск, клавиатура и мышь вокруг процессора сделаны мной с тем же интерфейсом, что у Вирта, и видеоконтроллер, который на настоящей машине отбирает у процессора немного времени, в замерах не участвует.
Сначала мне говорили, что это плохая идея, потому что пересчитывать каждый провод слишком медленно для браузера. На практике же браузерная версия работает почти так же быстро, как та же симуляция, запущенная прямо на компьютере, разница в пару процентов. Это примерно в шесть раз медленнее настоящей машины Вирта, так что система грузится несколько секунд, а дальше с ней вполне можно работать. А вся машина вместе с образом диска весит около трёхсот килобайт, то есть меньше, чем средняя картинка на любом новостном сайте.

Что пришлось сделать, чтобы это заработало
Сборка под WebAssembly потребовала нескольких заглушек для функций, которых в браузере нет, и пары обходных манёвров в самом Verilator, но это скучная часть. Интереснее было дальше.
Если открыть страницу в фоновой вкладке, браузер не рисует на ней ничего, и экран оставался чёрным даже после того, как вы на эту вкладку переходили. Пришлось отдельно ловить момент, когда вкладка становится видимой.
С мышью было интереснее всего. Оберону нужно знать, какие из трёх кнопок нажаты одновременно, а браузер сообщает о нажатиях по отдельности, поэтому состояние всех кнопок приходится собирать самому. Среднюю кнопку на ноутбуке изображает щелчок с зажатым Alt, и не с Ctrl, как хотелось бы, потому что на Маке Ctrl-щелчок — это правая кнопка. А щелчок с Shift изображает хитрую комбинацию, когда нажимают левую кнопку и, не отпуская её, добавляют правую. Страница сама сначала нажимает левую, а потом добавляет правую, потому что Оберону важно, с какой кнопки всё началось.
Сама машина работает в отдельном потоке, чтобы не подвешивать страницу, и передаёт готовые кадры экрана в основной поток без копирования. А ещё она считает, только пока на неё кто-то смотрит, и если вкладка скрыта или вы прокрутили машину за край экрана, она останавливается. Иначе она честно сжигала бы целое ядро процессора вашего компьютера, потому что схема не умеет простаивать и просто считает такты.
Благодаря этому машину можно вставить в любую страницу двумя строчками:
<script type="module" src="https://tym83.github.io/paleocomputing/oberon/embed.js"></script>
<oberon-machine base="https://tym83.github.io/paleocomputing/oberon/"></oberon-machine>
Подробности и настройки есть на странице Вставить к себе. Там же можно переключить машину на процессор с новой командой проверки и положить на её диск свои файлы ещё до загрузки.
Страница, на которой меняется процессор
Изначально я хотел, чтобы читатель мог сам поменять систему команд процессора прямо на странице. Этого я, честно признаюсь, не сделал, потому что пересобирать схему из Verilog прямо в браузере оказалось слишком тяжело. Сработала запасная идея из того же плана: заранее собрать несколько вариантов процессора и дать переключаться между ними. На странице Поменять процессор лежат два ядра, обычное, как у Вирта, и с новой командой CHK, и на каждом можно запустить один и тот же цикл, который бегает по массиву.
На обычном процессоре одно обращение к массиву в этом цикле занимает одиннадцать тактов, из которых два уходят на программную проверку. На процессоре с новой командой — десять, потому что проверка занимает один такт вместо двух. Один такт из одиннадцати, ровно как задумывалось. Та самая скучная строчка из таблицы рисков сбылась:)

Как этот замер сначала соврал вдесятеро
В первой версии страница запускала программу до конца, а момент окончания отслеживала, заглядывая в память раз в двести тысяч команд. В результате в замер попадал ещё и холостой хвост после окончания цикла, и получалось, что проверка стоит не одну команду, а десять. Выглядело это вполне правдоподобно, и я чуть было в это не поверил. Теперь цикл заведомо длиннее, чем мы меряем, обе версии процессора выполняют ровно одинаковое число команд, а сколько раз успел пройти цикл, программа сама записывает в регистр.
Главное, что проверяет эта страница, даже не цена проверки. Обычная система, без всяких новых команд, должна загружаться на обоих процессорах совершенно одинаково, с тем же изображением на экране и тем же числом выполненных команд. Если после добавления новой команды старые программы ведут себя хоть чуть-чуть иначе, значит, я сломал совместимость и получил уже какую-то другую машину.

Тринадцать лабораторных
Раз уж машина работает в браузере, на ней можно учиться. В лаборатории тринадцать заданий, и каждое проверяется машиной, а не на честное слово. Проверка сама заглядывает в память, регистры или на диск эмулируемого компьютера и смотрит, сделали ли вы то, что просили. Задания разбиты по уровням: сначала просто смотреть, потом менять, ломать, измерять и, наконец, строить своё.

А вот так выглядит интерфейс Оберона в деле. Средний щелчок по тексту System.ShowModules открывает список загруженных модулей, а ещё один, по Hilbert.Draw, рисует кривую Гильберта. Никаких кнопок, только текст:

№ | Задание | Уровень | Что узнаёте |
|---|---|---|---|
1 | Система на настоящем железе | смотреть | что под картинкой работает схема Вирта и как устроен интерфейс, где любой текст может быть командой |
2 | Первый свой модуль | смотреть | как набрать, сохранить и скомпилировать программу во встроенном редакторе |
3 | Ключ интерфейса | менять | почему в Обероне нет заголовочных файлов и как система защищается от несовместимых модулей |
4 | Защиты памяти здесь нет | ломать | что будет, если записать мусор прямо в память экрана, и как одна команда убивает машину насмерть |
5 | Сколько тактов на команду | измерять | почему на машине без кэша такты можно посчитать в уме |
6 | Память кончается внутри команды | ломать | почему уборка мусора работает только между командами |
7 | Система пересобирает сама себя | строить | как пересобрать модуль внутри системы и найти несогласованность образа своими руками |
8 | Два поколения компилятора | смотреть | почему компилятор, который собирает сам себя, ещё ничего не доказывает |
9 | Внутри компилятора | менять | где в компиляторе рождаются команды процессора |
10 | Уборщик мусора изнутри | смотреть | что уборка мусора — обычная задача, которую система вызывает раз в секунду |
11 | Одна задача за раз | ломать | почему одна зависшая задача останавливает всю систему |
12 | Цена проверки своими руками | измерять | как на своём цикле померить три варианта, без проверки, программную и аппаратную |
13 | Своя встроенная процедура | строить | как добавить в язык новую встроенную процедуру, пересобрав компилятор прямо внутри системы |
По умолчанию лаборатория открывается по-английски, а на русский переключается кнопкой или ссылкой с ?lang=ru, и выбор запоминается. Рядом лежит методичка на восемь глав, которая начинается с того, что здесь настоящее, и заканчивается тем, что мы измерили, и у неё тоже есть английская версия. Если вы преподаёте архитектуру компьютеров и хотите взять эти задания к себе, напишите мне, помогу завести. Они открытые, и машину можно встроить на свою страницу тем же тегом.
Что лабораторные показали мне самому
Уборщик мусора в Обероне очень ленивый. Он запускается, только если человек успел сделать двадцать действий или память почти кончилась, так что несколько сотен килобайт мусора могут лежать сколько угодно. А работает он только между командами потому, что не умеет просматривать стек, и все живые объекты ищет только через глобальные переменные модулей.
Автоматический набор текста в одной из лабораторных молча портил программу. Из-за ошибки в таблице клавиш не набиралась закрывающая скобка, но файл при этом исправно сохранялся, так что проверка, которая смотрела только на наличие файла, ничего не замечала.
А кнопка, которая откатывает машину к началу, долго не работала, потому что в схеме Вирта регистры процессора при сбросе не обнуляются. При первом запуске их обнуляет Verilator, а при повторном в них остаётся что было. На настоящей ПЛИС было бы точно так же, так что это костыль именно моего стенда.
Отдельный стыд этого раздела связан с тем, что лаборатория на сайте какое-то время была мертва. Список заданий был пустым, а на экране висела вечная загрузка. Сначала я нашёл одну ошибку в коде страницы, исправил, но это не помогло. Настоящая причина оказалась в том, что в файле страницы не было закрывающего тега у скрипта. Я считал это безобидным, мол, браузер простит, и даже научил автоматическую проверку сайта прощать такое. А по стандарту HTML, если файл закончился внутри незакрытого скрипта, браузер помечает этот скрипт как уже выполненный и просто не запускает его, не выдавая ни ошибки, ни предупреждения. Теперь автоматическая проверка открывает сайт в настоящем браузере и требует, чтобы заданий на странице было столько же, сколько в исходниках. В общем, про то, что браузер всё простит, я больше не говорю.
А сколько проверка стоит на современных процессорах?
Хорошо, на процессоре Вирта программная проверка стоит два такта из одиннадцати, а аппаратная — один. Но процессор Вирта — очень простая машина, которая выполняет одну команду за другой и ничего не угадывает. А как с этим обстоят дела на процессорах, которыми оснащены ноутбуки и серверы?
Я взял тот же самый цикл по массиву, написал его на C и на Rust в трёх вариантах. В первом проверки нет вообще, во втором она написана так, как её пишет сам язык, а в третьем компилятору запрещено её выбрасывать. Все три варианта я прогнал везде, до чего дотянулся. Сразу оговорюсь, потому что без этого числа читать нельзя: цикл нарочно выбран самым удобным для проверки. Массив маленький и всегда лежит в самой быстрой памяти процессора, а проверка всегда проходит, так что процессору нетрудно угадать её результат. Кроме того, я запретил компилятору векторизацию, то есть обработку нескольких элементов массива одной командой, хотя в настоящем коде проверки дороги в первую очередь тем, что мешают как раз ей. Мне хотелось увидеть саму проверку, без всего остального.
Процессор | Насколько проверка замедляет этот цикл |
|---|---|
RISC5, программная проверка | на 22% |
RISC5, новая команда | на 11% |
Apple M4 | не видно, тонет в шуме |
AMD EPYC | не видно, тонет в шуме |
Серверный Arm Neoverse N2 | на 2–6% |
CHERIoT, аппаратная проверка | ни одной лишней команды |
Во-первых, сама проверка за сорок лет вообще не изменилась, и везде это те же самые сравнение и условный переход, что у Вирта. Во-вторых, проверка в том виде, в каком её пишет язык, не стоила ничего вообще нигде, потому что современные компиляторы C и Rust сами догадались, что индекс в этом цикле никогда не выходит за границу, и выбросили проверку. Компилятор Вирта так не умеет, он оставляет проверку всегда.
А почему на AMD и Apple проверку не видно, а на серверном Arm видно? Мне это кажется самым интересным результатом первой половины проекта. Современный процессор выполняет несколько команд за один такт и сам переставляет их местами, чтобы загрузить все свои исполнительные блоки. Если у программы мало работы, лишние команды проверки просто попадают в свободные места и ничего не стоят, как пассажир, который сел в полупустой автобус и никого не потеснил. Я это проверил, постепенно добавляя в цикл работу и проверки и глядя, когда проверки начнут стоить времени. На AMD одна-две проверки действительно ничего не стоят, а с четвёртой уже начинают. А на серверном Arm каждая проверка добавляет немного времени с самого начала, потому что свободных мест у этого ядра меньше. У процессора Вирта свободных мест нет вообще, он выполняет одну команду за раз, и каждая команда проверки — это такт, который всегда оплачивается.
Что делает CHERI
CHERI — это архитектура, в которой указатель знает свои границы, то есть вместе с адресом хранит, где начинается и где кончается область памяти, в которую ему можно ходить, а процессор проверяет эти границы сам при каждом обращении к памяти. Такой указатель вдвое шире обычного адреса, и подделать его программа не может. Мне хотелось поставить CHERI на ту же лестницу, и не на большом сложном процессоре, где лишняя команда может стоить сколько угодно, а на самом близком к процессору Вирта родственнике. Я взял CHERIoT-Ibex, простое 32-битное ядро, которое тоже выполняет команды по одной и не имеет кэша, и прогнал тот же цикл на его схеме.
Результат получился очень наглядным. На CHERI проверка не занимает в цикле ни одной команды, потому что она встроена в само чтение из памяти. Цикл с защитой ровно такой же длины, как цикл без неё. Чтобы убедиться, что защита действительно работает, я сузил границы указателя до 32 элементов, и программа упала ровно на 33-м. А варианта без проверки на CHERI нет и не будет, потому что выключить её просто нечем. Опубликованные работы это подтверждают. В CHERI сама проверка почти бесплатна, а платить приходится за ширину указателя, потому что широкие указатели занимают вдвое больше места в памяти и в кэше.
Так получилась лестница. На процессоре Вирта программная проверка стоит две команды и два такта, новая команда CHK — одну команду и один такт, а CHERI — ноль команд. Широкие современные процессоры стоят сбоку. Команд у них столько же, сколько у Вирта, но пока у ядра есть свободные места, эти команды почти ничего не стоят. А между CHK и CHERI на этой лестнице долго зияла дыра, которую я закрыл уже в самом конце этих девяти дней.
Дескрипторы между CHK и CHERI
Команда CHK проверяет индекс по длине, которую компилятор зашил в саму команду, а значит, компилятор должен знать длину массива заранее. CHERI хранит границы в самом указателе, и проверку нельзя ни забыть, ни обойти. Между этими двумя крайностями исторически были ещё и дескрипторы, как в том самом Burroughs B5000 начала шестидесятых. Это указатель, который носит длину массива с собой, а процессор проверяет её при каждом обращении, и компилятору уже не нужно ничего знать заранее. Мне хотелось поставить эту ступень на лестницу и измерить её на той же полностью открытой системе.
Первое же наблюдение сильно сузило задачу. В Обероне длину почти любого массива компилятор знает заранее, потому что указателей на массивы и массивов переменной длины в языке просто нет. Исключение одно, это случай, когда массив передают в процедуру, которая готова принять массив любой длины. Внутри такой процедуры длина заранее неизвестна, и именно там дескриптору есть что делать. Во всех остальных местах CHK уже справляется.
Дескриптор я уложил в обычное 32-битное слово. Адрес у процессора Вирта 24-битный, но памяти у машины всего мегабайт, а для мегабайта хватает двадцати бит, так что двенадцать оставшихся бит я отдал под длину массива, то есть до четырёх тысяч с небольшим элементов. И к этому одна новая команда, которая берёт дескриптор и индекс, вычисляет адрес элемента и, если индекс вылез за длину, переходит на обработчик ошибки. Обычный адрес, если подать его вместо дескриптора, выглядит как массив нулевой длины, так что любое обращение по нему сразу считается ошибкой. Это ближайший аналог главного правила CHERI, по которому без права нет и доступа, какой можно сделать без специальной аппаратной метки.
Проверяли новую команду так же строго, как CHK, теми же намеренными поломками, пошаговой сверкой с эмулятором и проверкой, что обычная система на процессоре с новой командой грузится так же, как на обычном. И снова первая попытка соврала. Прогон с намеренными поломками сначала сообщил, что поймал их все. Оказалось, что скрипт, который вносил поломки, сам падал, схема не собиралась, а то, что ничего не собралось, засчитывалось как пойманная поломка. Теперь несобравшаяся схема считается провалом проверки, а не успехом.
На том же цикле, что на странице с двумя ядрами, дескриптор оказался быстрее всех, даже быстрее варианта вообще без проверок, восемь тактов на обращение против девяти. Никакого чуда в этом нет. Новая команда заодно вычисляет адрес элемента массива, на что обычно уходят ещё две команды, и если бы я сделал такую же команду без проверки, она работала бы так же быстро. Урок здесь другой, и он мне нравится. Если граница едет вместе с указателем, проверку можно спрятать внутри команды, которая всё равно нужна. Ровно так и сделано в CHERI, где проверка сидит внутри чтения из памяти.
Дальше я научил компилятор использовать дескрипторы там, где длина заранее неизвестна, и попросил систему пересобрать себя с новым компилятором. Она пересобралась, и следующее поколение компилятора совпало с предыдущим байт в байт. Это важная проверка, потому что если бы где-то компилятор забыл превратить дескриптор обратно в обычный адрес, получился бы неверный адрес, и совпадения не было бы.
А дальше настоящая система нашла две границы, до которых я сам бы не додумался. Первая связана с длиной массива. Во всей системе Project Oberon нет ни одного массива длиннее четырёх тысяч элементов, но в инструментах, которыми я её собирал, нашёлся буфер на шестнадцать тысяч, и компилятор честно отказался его собирать, вместо того чтобы молча обрезать длину. Вторая граница оказалась интереснее. Когда я собирал много модулей за один запуск, память в эмуляторе переваливала за мегабайт, а мой дескриптор умеет адресовать только мегабайт, и сборка просто молча зависала. Отсюда очень наглядный вывод. Указатель с границами нельзя уместить в ширину обычного адреса, потому что как только памяти становится больше, места под длину не остаётся. Ровно поэтому указатели в CHERI вдвое шире адреса и вдобавок хитро сжимают границы.
Во что обходится вся эта конструкция на больших нагрузках? На синтетической программе, которая много работает с массивами неизвестной длины, дескрипторы дают ускорение примерно на четверть, по той же причине, что и в цикле: они экономят на вычислении адреса. А на компиляторе, то есть на настоящей программе, они, наоборот, дают крошечное замедление, меньше процента, и я довольно долго искал, откуда оно берётся, потому что по расчёту должно было выйти ускорение. Оказалось, что дело вовсе не в дескрипторах: каждый раз, когда программа передаёт в процедуру строку, компилятор теперь собирает для неё дескриптор, из-за этого код стал немного длиннее, и система тратит чуть больше времени на загрузку модулей. Если бы дескрипторы для строк компилятор готовил заранее, этого бы не было, но это я уже не делал.
На ПЛИС процессор с дескрипторами по-прежнему держит свои 25 МГц, логики у него примерно на пять процентов больше, а новая команда впервые оказалась на самом длинном пути сигнала в схеме, то есть в будущем может ограничить частоту. У CHK такого не было ни разу.
В итоге лестница выглядит так:
Ступень | Где хранится граница | Команд проверки в цикле | Главная цена |
|---|---|---|---|
RISC5, программная проверка | в коде | 2 | медленнее всего |
RISC5, | в самой команде | 1 | не работает, если длина заранее неизвестна |
RISC5, дескриптор | в указателе | 0 | память до мегабайта, массивы до четырёх тысяч элементов |
CHERI | в широком указателе с меткой | 0 | указатели вдвое шире |
Дескриптор убирает проверку из цикла так же, как CHERI, но в Обероне ему почти нечего защищать сверх того, что уже делает CHK. И главное, что отличает его от CHERI: дескриптор остаётся обычным числом, и программа, которой разрешены низкоуровневые операции, может сложить из любой длины и любого адреса любой дескриптор. Ровно в это место CHERI ставит специальную метку, которую подделать нельзя. Систему, целиком собранную с дескрипторами, на настоящей схеме я пока не загрузил, в браузере и в каталоге этого процессора тоже пока нет, а все подробности и команды для повтора лежат в описании эпизода.
Машина Вирта в QEMU
Браузер — это прекрасно, но мне хотелось, чтобы машина Вирта жила там же, где живут обычные виртуальные машины, со своей консолью, дисками, перезапуском и всей обвязкой нормального гипервизора. Почти все виртуалки в Linux так или иначе запускает QEMU, программа, которая умеет изображать компьютеры самых разных архитектур. Процессора Вирта в ней, конечно, нет, так что его пришлось написать с нуля, то есть научить QEMU понимать все команды RISC5, его своеобразную арифметику с дробными числами и собрать вокруг процессора плату с памятью, диском, клавиатурой, мышью и экраном.
У нас при этом было одно преимущество, которого нет почти ни у кого, кто пишет для QEMU новую архитектуру. Обычно правильность такой работы проверяют по документации и наборам тестов, а у нас была сама схема процессора и эталонный эмулятор, уже сверенный с ней на пятнадцати миллионах команд. Так что QEMU мы сверяли не с бумагой, а со схемой, команда за командой, тем же приёмом пошаговой сверки. На полутора миллионах команд загрузки расхождений не нашлось, изображение на экране совпало со схемой до последней точки, а список модулей, который открывается по щелчку в System.ShowModules, оказался точно таким же, с теми же адресами в памяти. Число тёмных точек на экране после загрузки, 18 607, дальше стало для меня эталоном, и где бы ни запускалась машина, я сравнивал экран именно с ним.

Правило, которого нет ни в одном описании процессора
Первое расхождение в пошаговой сверке случилось очень рано, на двести двадцать четвёртой команде, и загрузчик даже не доходил до первого чтения с диска. Оказалось, что процессор Вирта обновляет флаги, которые показывают, отрицательный ли результат и не ноль ли он, при любой записи в регистр, в том числе когда просто загружает число из памяти. В описании процессора этого нет, а в схеме есть, и загрузчик Вирта на это рассчитывает. Сразу после загрузки числа из памяти он проверяет, не ноль ли оно, не делая отдельного сравнения. Вот ради таких вещей и нужно сверяться со схемой, а не с документацией.
Дробные числа я тоже сделал сам, а не взял готовые из QEMU, потому что арифметика у Вирта нестандартная. Она по-другому округляет, иначе обращается с очень маленькими числами и так далее, и стандартная реализация дала бы правдоподобные, но другие результаты. Первая сверка показала два десятка расхождений, и я уже собирался чинить свой код, но оказалось, что ошиблась сама сверка, а арифметика сошлась с первого раза. Если бы я ей поверил, я бы сломал работающий код.
Собрать и запустить можно так (подробно — в инструкции по QEMU):
git clone https://github.com/tym83/paleocomputing && cd paleocomputing
make -C qemu build # собирает QEMU в Docker-контейнере
docker create --name oberon-payload ghcr.io/tym83/paleocomputing/oberon-run:v0.1.17
docker cp oberon-payload:/opt/oberon/payload/prom.bin .
docker cp oberon-payload:/opt/oberon/payload/oberon.dsk .
docker rm oberon-payload
.qemu-work/build/qemu-system-risc5 -machine oberon -bios prom.bin \
-drive if=none,id=sd0,file=oberon.dsk,format=raw -vnc :0
Первая команда собирает QEMU с нашим процессором, следующие три достают из готового образа загрузчик и диск с системой, а последняя запускает машину. Экран смотреть любым VNC-клиентом на 127.0.0.1:5900, а если добавить к -machine oberon параметр chk=on, получится процессор с аппаратной проверкой. Загрузка без аппаратного ускорения занимает до минуты, так что не пугайтесь, если сначала увидите только экран загрузчика.
Почему это отдельная сборка, а не правка в основной QEMU, тоже стоит объяснить. Проекты QEMU и libvirt не принимают код, в написании которого участвовала языковая модель, причём даже если это только подозревается. У меня публичный репозиторий, в котором откровенно написано, как он сделан, так что отдать это в основной проект можно будет, только если кто-нибудь перепишет код руками. Лицензия GPL при этом прямо разрешает свою сборку, а самая трудная часть работы, то есть точное описание поведения процессора и способ проверки любой реализации, остаётся полезной в любом случае. Ещё одна неприятность в том, что наш процессор написан под самую свежую версию QEMU, поэтому уже выпущенные версии его не соберут (но вы можете это реализовать и сами).
libvirt, который не верит на слово
Следующий слой — libvirt. Это библиотека, через которую с QEMU разговаривает почти всё, что управляет виртуалками в Linux, от консольной утилиты virsh до KubeVirt, о котором речь пойдёт дальше. Вопрос был в том, можно ли подключить к ней новую архитектуру вообще без правок. Оказалось, что нельзя, но не по той причине, которую я ожидал.
Сначала я просто указал в описании машины архитектуру risc5, и libvirt сразу отказался, сказав, что не знает такой архитектуры. Тогда я схитрил, назвался знакомой ему архитектурой, а подсунул свою программу. libvirt отказался снова, но уже глубже. Он не верит тому, что написано в описании машины, а спрашивает у самого QEMU, что тот умеет изображать, и QEMU честно называет себя risc5, а такого имени в списке libvirt нет. Обмануть это описанием машины невозможно.
Так что без правки libvirt не обойтись, и правка вышла совсем маленькой, около десяти строк в пяти местах. Сделал я её так, чтобы список архитектур брался из отдельного файла, и следующая старая машина была бы новой строчкой в этом файле, а не новым кодом. Каждую правку сборка проверяет отдельно, потому что правка, которая молча не применилась, хуже, чем никакой. libvirt соберётся без ошибок, но архитектуру знать не будет. Это пригодилось сразу, когда в новой версии libvirt нужный кусок кода переехал в другой файл, и сборка громко об этом сообщила, вместо того чтобы тихо собрать неработающую библиотеку.
Как libvirt падал на честный ответ
После правки libvirt узнал архитектуру, но при первом же опросе нашего QEMU падал с обращением по пустому указателю. Оказалось, что он спрашивает у эмулятора список поддерживаемых моделей процессора, а наш QEMU честно отвечал, что моделей у него нет, потому что у машины Вирта процессор ровно один. На такой ответ libvirt просто не рассчитан. Можно было научить libvirt переживать такой отказ, что правильнее по существу, а можно было объявить в QEMU одну-единственную модель процессора. Я выбрал второе, потому что это меньше правок в чужом коде.
И тут же хорошая история о том, почему глазам верить нельзя. Первый снимок экрана из-под libvirt выглядел совершенно правильным, но побайтовая сверка с эталоном нашла несколько тысяч различий. Я успел заподозрить мышь, момент снятия снимка и сам libvirt, а оказалось, что я просто неправильно пересчитал адрес видеопамяти из шестнадцатеричной записи в десятичную и промахнулся на 512 байт. Это ровно четыре строчки экрана, и на глаз разница совершенно незаметна. После исправления экран совпал с эталоном байт в байт.

KubeVirt без форка
KubeVirt позволяет запускать виртуальные машины в Kubernetes так же, как обычные контейнеры, и управлять ими теми же инструментами. Внутри у каждой такой виртуалки есть служебный под, в котором живут те самые libvirt и QEMU. Мне нужно было подсунуть туда машину совсем другой архитектуры, и при этом не делать собственную копию ни KubeVirt, ни Cozystack. Своя копия большого проекта остаётся с тобой навсегда, и её приходится вручную тащить через каждое обновление, так что я очень хотел этого избежать.
Выручила штатная точка расширения, про которую мало кто знает. KubeVirt умеет перед запуском виртуалки отдать её описание стороннему обработчику и запустить то, что тот вернёт. Обработчик при этом может быть обычным скриптом, который лежит в настройках Kubernetes, так что даже собирать для него отдельный образ не нужно. Наш обработчик получает описание самой обычной виртуалки с Intel-процессором, дисками и сетью и переделывает его в машину Вирта. Он меняет архитектуру, выключает аппаратное ускорение, которого для чужой архитектуры всё равно не бывает, подставляет наш эмулятор, загрузчик и образ диска и выбрасывает всё, чего у машины Вирта нет, а нет у неё почти ничего из того, что KubeVirt добавляет по умолчанию.
Сам эмулятор и исправленный libvirt приходится положить в тот служебный образ, из которого KubeVirt запускает все виртуалки. Всё остальное KubeVirt и Cozystack умеют сами. Так что для машины Вирта от кластера нужно ровно две вещи, которые обычный пользователь сделать не может. Нужно разрешить сторонние обработчики и поставить наш служебный образ. Администратор соглашается на это один раз, а дальше любой пользователь ставит машину из каталога, примерно как устанавливают пакет драйверов.
Отказы, которые видны только на настоящем KubeVirt
Сначала я прогнал через обработчик описание, которое KubeVirt даёт обычной виртуалке, и скормил результат libvirt у себя на столе. Всё запустилось, экран совпал с эталоном, и я решил, что готово. На настоящем KubeVirt внутри нашего Cozystack машина не запустилась, и отказов было четыре, причём ни одного из них на столе не было.
Сначала QEMU отказался, потому что KubeVirt требует у машины поддержку управления питанием, которой у машины Вирта нет. Потом отказался, потому что KubeVirt просит разрешить добавлять процессоры на ходу, а у машины Вирта процессор один и больше не бывает. Третий отказ был самым смешным. Я убрал из описания раздел со сведениями о системе, и упал уже сам KubeVirt, который читает этот раздел после запуска. Вернул раздел, и снова отказался QEMU, потому что не поддерживает такие сведения для этой архитектуры. В итоге оказалось, что раздел нужно оставить, а убрать одну маленькую настройку, из-за которой libvirt передаёт его в QEMU. С тех пор я убираю ровно то, от чего отказываются, и ни строчкой больше, потому что широкий взмах веником ломает то, что работало. А четвёртый отказ случился, когда я обновил KubeVirt, а наш служебный образ был собран для предыдущей версии. Теперь образ собирается отдельно под каждую поддерживаемую версию KubeVirt.
Со служебным образом была ещё одна тихая ловушка. Мы берём штатный образ KubeVirt и заменяем в нём только libvirt, и версия нашего libvirt обязана точно совпадать с той, что уже лежит в образе. Если ошибиться, образ соберётся без единой ошибки, но наша библиотека ляжет рядом со штатной, а не вместо неё, и работать будет штатная. Поэтому теперь сборка проверяет, что libvirt в образе ровно один, а соответствие версий KubeVirt и libvirt записано в одном файле, и руками его задать нельзя.
Шестнадцать секунд до переезда всего кластера
Это, наверное, самая поучительная история проекта, и она про мою собственную невнимательность, код тут ни при чём.
Служебный образ, из которого KubeVirt запускает виртуалки, задаётся сразу на весь кластер. Чтобы его подменить, я поправил настройки KubeVirt прямо на живом кластере, нашем тестовом стенде для воркшопов и лабораторок (привет, Захар!), на котором крутились в том числе учебные виртуалки. Через шестнадцать секунд KubeVirt начал переносить на новый образ все виртуальные машины кластера, не останавливая их. Дело в том, что в Cozystack по умолчанию включено автоматическое обновление работающих машин, и KubeVirt сделал ровно то, что ему сказали. Раз служебный образ поменялся, все машины нужно переселить на новый. За пять часов он попытался перенести машины больше ста раз, и больше половины попыток провалилось. Никто при этом не пострадал и не потерял данные, а успешные переносы заодно показали, что наш образ переносить машины умеет, но в целом картина была так себе. Остановил я это так:
kubectl -n cozy-kubevirt patch kubevirt kubevirt --type=merge \
-p '{"spec":{"workloadUpdateStrategy":{"workloadUpdateMethods":[]}}}'
Эта команда выключает автоматическое обновление машин, но это стоп-кран, а не решение. Уже начатые переносы она не отменяет, при следующем обновлении Cozystack настройка вернётся, а жить совсем без автообновления тоже плохо, потому что после обновления KubeVirt машины останутся на старом служебном образе. Самое обидное в этой истории то, что я знал, где это смотреть. Я просто проверил, что новый образ существует, и не подумал проверить, что он сделает с кластером.
Почему провалилась половина переносов, стало понятно из журналов, и образ тут был ни при чём. KubeVirt по умолчанию переносит с одного сервера не больше двух машин одновременно, остальные ждут своей очереди и часто не дожидаются. А ещё переносу мешали квоты. На время переноса машина существует в двух экземплярах сразу и занимает вдвое больше памяти, так что машина, которая целиком съела квоту своего пользователя, живьём не переедет никогда. Квоту нужно брать с запасом хотя бы на самую большую машину.
Внешний маркетплейс для Cozystack
Cozystack — это открытая платформа, из которой на Kubernetes собирают своё облако. Мы в Ænix делаем её вместе с сообществом, и она входит в CNCF, фонд, где живёт и сам Kubernetes. У платформы есть веб-интерфейс, пользователи, которых здесь называют тенантами (это как отдельные аккаунты в облаке), виртуальные машины и каталог приложений, где лежат базы данных, кластеры Kubernetes, кэши и прочее. А недавно в сообществе появился механизм подключаемых каталогов. Любой может опубликовать свой набор приложений и подключить его к своей платформе, и тогда эти приложения появляются у пользователей рядом со штатными. Для палеокомпьютинга я сделал именно такой каталог, ничего не придумывая сверх того, что уже есть в платформе.
Каталог разбит на четыре части, потому что доверять им нужно по-разному. В первой лежат машины и окружения для пользователей, то есть настоящая виртуалка с процессором Вирта, браузерная лаборатория, методичка и набор, который ставит всё это разом. Во второй лежит среда языка Оберон, в которой можно запускать программы на нём как обычные задачи в кластере. Третья кладёт загрузочные образы в общее хранилище платформы, а четвёртая подменяет тот самый служебный образ KubeVirt. Эти две последние части трогают весь кластер, поэтому ставятся только с явного согласия администратора.
Подключается каталог так:
cozypkg tap oci://ghcr.io/tym83/paleocomputing/machines:v0.1.17
cozypkg tap oci://ghcr.io/tym83/paleocomputing/languages:v0.1.17
cozypkg add paleocomputing.machines
cozypkg add paleocomputing.languages
# то, что трогает весь кластер, ставится с явного согласия администратора
cozypkg tap oci://ghcr.io/tym83/paleocomputing/images:v0.1.17
cozypkg tap oci://ghcr.io/tym83/paleocomputing/platform:v0.1.17
cozypkg add paleocomputing.platform --allow-privileged
Команда tap только подключает каталог к платформе, а add ставит его содержимое. Это различие меня самого когда-то запутало, и в документации его сначала вообще не было.
Прежде чем ставить последнюю часть, обязательно посмотрите на настройку автоматического обновления машин в KubeVirt. С настройками Cozystack по умолчанию смена служебного образа отправит в переезд все виртуалки кластера, и это случится при установке, при удалении и при каждом обновлении KubeVirt. Ровно то, что случилось со мной. Эту дыру нашёл один из рецензентов уже при проверке этой статьи, так что теперь, если автообновление включено, компонент ничего не меняет и ждёт, пока администратор явно не разрешит переезд. Разрешить можно настройкой allowWorkloadUpdate: true при установке или аннотацией paleocomputing.io/allow-workload-update=true на ресурсе KubeVirt. И ещё одно. Утилита подключения каталогов пока не проверяет цифровую подпись, так что если вы ставите это куда-то серьёзнее домашнего стенда, проверьте подпись сами, как это сделать, написано в инструкции.
После подключения у пользователей в каталоге появляется свой раздел Paleocomputing. Правда, есть нюанс, на который я наткнулся уже при съёмке скриншотов для этой статьи. Текущий веб-интерфейс Cozystack показывает в боковом меню только три раздела, которые прямо прописаны в его коде, а все остальные прячет в самом конце полного списка приложений. Я свой раздел сначала и сам не нашёл:) А ведь смысл подключаемого каталога как раз в том, чтобы приносить свои разделы, а не растворяться в чужих. Так что это мы будем решать в самом Cozystack, и интерфейс должен будет показывать любые разделы, которые принёс каталог.

Машина ставится формой в веб-интерфейсе или одним коротким описанием:
apiVersion: apps.cozystack.io/v1alpha1
kind: OberonVM
metadata:
name: wirth
spec:
memory: 128Mi
hardware: chk # или base, обычный процессор Вирта


Экрана машины прямо в веб-интерфейсе пока нет, в текущих версиях он есть только у обычных виртуалок. Посмотреть на него можно утилитой virtctl, с правами самого пользователя и любым VNC-клиентом:
virtctl -n tenant-sandbox vnc oberon-vm-oberon-vm-habr

Устроено это так, чтобы следующая старая машина не требовала нового кода. Всё, чем машина Вирта отличается от других, записано в одном небольшом файле, который я называю паспортом машины. Это архитектура, эмулятор, загрузчик, диск, варианты процессора и пределы памяти. Всё остальное общее, и обработчик для KubeVirt тоже один на все машины, он просто читает паспорт. Следующая машина, например Lilith, это новый паспорт, а не копия кода. Чтобы убедиться, что это не пустые слова, в тестах есть вторая, вымышленная машина другой архитектуры, и она собирается без единой правки кода.
Как выглядит паспорт машины
kind: Machine
name: oberon
image: ghcr.io/tym83/paleocomputing/oberon-run:v0.1.17
domain:
arch: risc5
machine: oberon
emulator: /usr/local/bin/qemu-system-risc5
vcpus: 1
graphics: vnc
terminationGracePeriodSeconds: 0
payload:
path: /payload
files:
- {name: prom.bin, role: firmware, qemu: ["-bios", "{path}"]}
- {name: oberon.dsk, role: disk, qemu: ["-drive", "if=none,id=sd0,file={path},format=raw"]}
variants: {base: {}, chk: {chk: "on"}}
memory: {default: 128Mi, min: 128Mi, max: 1Gi}
Время на корректное выключение тут стоит нулевое, потому что машина Вирта не слышит просьбу выключиться, и KubeVirt впустую ждал бы её полминуты при каждом перезапуске. Загрузчик при каждом обновлении каталога заменяется новым, а диск кладётся один раз и дальше принадлежит пользователю, так что его файлы переживают обновления.
Всё зелёное, и ничего не работает
Пока я возился с кластером, одна и та же ловушка повторилась шесть раз за один вечер, и я завёл под неё отдельную заметку с таким названием. Каждый раз проверка сообщала об успехе, а на самом деле ничего не работало. Каталог собран, а внутри нет главного файла. Установка прошла успешно, а машина не запущена. Сервер отвечает, что всё в порядке, а в образе нет самой машины. Сборка зелёная, а копировался файл, которого не существует. Потом был и седьмой раз, те самые шестнадцать секунд.
Что видно только на живом кластере
Образ браузерной лаборатории долго публиковался без самой машины. Страница была, а процессора и диска не было, и проверка довольствовалась тем, что страница отдаётся. Заодно выяснилось, что из-за одной строчки в списке игнорируемых файлов и того, что файловая система Мака не различает большие и маленькие буквы, в репозиторий никогда не попадала целая библиотека языка из сорока одного файла.
Машина с процессором с аппаратной проверкой запускалась, но на самом деле на обычном процессоре. Оказалось, что в пакете для Cozystack лежала своя копия обработчика, а я правил другую. Теперь автоматическая проверка сравнивает копии.
Экран машины в Kubernetes сначала был пустым, потому что обработчик вместе со всем лишним выбросил и вывод изображения. Когда я его вернул, QEMU перестал запускаться, потому что в образ не положили раскладки клавиатуры.
Одна служебная задача зависала без всяких ошибок. В Cozystack обращаться к управляющему серверу Kubernetes могут только поды с особой меткой, а остальным сетевой слой молча не отвечает. Если бы не хватало прав, отказ пришёл бы сразу, так что как раз зависание и было подсказкой.
А с цифровой подписью вышло так, что ни один мой выпуск не совпадал с тем, что ожидает каталог сообщества, потому что каталог ждёт подпись от сборки из основной ветки, а я выпускал по тегу. Нашлось это чтением исходников утилиты, которая, как выяснилось, подпись вообще не проверяет.
27 сентября вечером случился эпизод, за который мне (это уже Клоду, а не мне, наши голоса регулярно переплетаются) до сих пор немного стыдно. Мы переделывали то, как машина описывается в кластере, и релизы пошли один за другим. Первый остановился на собственных проверках. Второй намертво завис, потому что задача, которая готовит диск машины, ждала запуска машины, а машина ждала диск. Третий нашёл ещё две ошибки. В этот момент я (уже прям я, а не Клод) не выдержал и спросил агента, почему мы выпускаем релиз за релизом и нельзя ли сразу нормально всё проверить (в оригинале это звучало чуть покрепче).
Можно, и после этого процесс выпуска поменялся. Теперь каждое изменение сначала собирается как тестовая версия, отдельная песочница в кластере переключается на неё, и сценарий от имени обычного пользователя ставит машину, проверяет, что она запустилась на нужном процессоре, сравнивает экран с эталоном, перезапускает, удаляет и смотрит, что после неё ничего не осталось. Только если всё это прошло, изменение попадает в основную ветку и становится выпуском. Второй такой сценарий проверяет компонент, который меняет служебный образ, и заодно запускает обычную Ubuntu на нашем образе, чтобы убедиться, что соседей мы не сломали. Первым выпуском, прошедшим всё это до публикации, стал v0.1.14, а застрявшие машины после него поднялись сами.
Забавно, что в самом этом сценарии тоже нашлись ошибки, хотя система при этом работала. Например, загрузку Ubuntu сначала проверяли по программе-помощнику внутри гостевой системы, которой в чистом образе Ubuntu просто нет, так что проверка висела бы вечно. Потом проверяли по приглашению ко входу в консоли, а консоль без настоящего терминала молчит. А функция ожидания считала только паузы, а не всё время целиком, и двадцать минут ожидания превращались в два часа. Скрипт терпеливо ждал рядом с давно загрузившейся Ubuntu.
Компонент, который следит за служебным образом
Подменять служебный образ KubeVirt руками — плохая идея, и причин у этого три. Он общий для всех виртуалок кластера, так что ошибка ломает всех. Он обязан совпадать с версией KubeVirt, а после обновления Cozystack он останется старым и сломает все машины. И мой первый ручной способ заодно замораживал ещё несколько настроек KubeVirt, которые я вообще не собирался трогать.
Поэтому в каталоге есть отдельный компонент, который раз в полминуты смотрит на настройки KubeVirt и приводит в порядок свою часть. Если для текущей версии KubeVirt у нас есть образ, он ставит его. Если нет, убирает свою правку, и кластер возвращается к штатному образу. Машины Вирта тогда не запустятся, зато все остальные будут работать. Если хоть что-то выглядит сомнительно, он тоже убирает правку, а если не смог прочитать настройки, не трогает ничего. Свою правку он вносит так, что если KubeVirt когда-нибудь поменяет устройство своих настроек, правка не подставит образ куда попало, а громко откажется применяться.
И этот компонент тоже поймала песочница. На живом кластере он падал, потому что передавал в одну из утилит сведения обо всех серверах кластера целиком, а на настоящем кластере эти сведения огромные. В тестах серверы были крошечные, а тестовый кластер состоял из одного сервера, так что там ничего не падало. Теперь тест с кластером на три тысячи больших серверов краснеет на старом коде.
Серверы на Intel и на ARM
Когда я начал собирать всё это ещё и под серверы на ARM, меня спросили, зачем вообще нужны сборки под разные процессоры, если мы уже добавили в KubeVirt архитектуру Оберона. Вопрос хороший, и путаница тут естественная, потому что архитектур здесь две. Одна — это архитектура машины Вирта, её изображает QEMU, и от сервера она не зависит. А другая — это настоящий процессор сервера, Intel или ARM. Эмулятор и libvirt — обычные программы, и под каждый процессор сервера их нужно собирать отдельно. Это как с эмулятором игровой приставки, где приставка одна, а версии эмулятора для Windows и для Мака разные.
Наш служебный образ был собран только под Intel. А поскольку он заменяет штатный образ для всех виртуалок кластера, на кластере из ARM-серверов не запустилась бы вообще ни одна машина, не только Оберон. Сборка под ARM прошла сразу, но настоящий запуск на ARM остановился трижды. Сначала оказалось, что образ с загрузчиком и диском существует только для Intel. Потом, что один из инструментов сборки тоже выпускается только для Intel. А третья проблема была самой интересной. На ARM KubeVirt всегда даёт виртуалке современную прошивку со своей флеш-памятью, а у машины Вирта никакой флеш-памяти нет, и QEMU выходил сразу после запуска. На Intel этого не видно, потому что там прошивка по умолчанию другая. Ни одну из этих трёх проблем не было видно ни в коде, ни в собранном образе, только на живом сервере с нужным процессором.
Сейчас всё это работает на двух версиях KubeVirt и на обоих типах процессоров, и экран совпадает с эталоном во всех четырёх сочетаниях. Честно оговорюсь, что на ARM я проверял голый KubeVirt в одноразовом тестовом кластере, а Cozystack на ARM-серверах пока не запускал.
Как поставить машину без Cozystack, на свой KubeVirt
Всё это работает и на обычном KubeVirt, без Cozystack, и автоматическая проверка доказывает это при каждом изменении. Она поднимает одноразовый кластер, ставит KubeVirt, подменяет служебный образ, запускает машину и сравнивает экран. Подробная инструкция лежит в kubevirt/GUIDE.ru.md, а если коротко, то шагов три:
# 1. разрешить сторонние обработчики (на серверах без /dev/kvm нужен ещё useEmulation: true)
kubectl -n kubevirt get kubevirt kubevirt -o json \
| jq '.spec.configuration.developerConfiguration.featureGates |= ((. // []) + ["Sidecar"] | unique)' \
| kubectl replace -f -
# 2. поставить служебный образ под вашу версию KubeVirt
git clone --depth 1 -b v0.1.17 https://github.com/tym83/paleocomputing && cd paleocomputing
KV=$(kubectl -n kubevirt get kubevirt kubevirt -o jsonpath='{.status.observedKubeVirtVersion}')
echo "$KV ghcr.io/tym83/paleocomputing/virt-launcher:$KV-paleo-v0.1.17" > /tmp/launchers.txt
kubectl -n kubevirt create configmap kubevirt-paleo-launcher-status
export KUBECTL=kubectl KUBEVIRT_NAMESPACE=kubevirt LAUNCHER_TABLE=/tmp/launchers.txt
R=marketplace/repos/platform/packages/system/kubevirt-paleo-launcher/files/reconcile.sh
until sh $R once && [ "$(kubectl -n kubevirt get cm kubevirt-paleo-launcher-status \
-o jsonpath='{.data.state}')" = Applied ]; do sleep 10; done
# 3. поставить машину обычным Helm и открыть её экран
python3 marketplace/tools/pin-images.py --release v0.1.17
helm install wirth marketplace/repos/machines/packages/apps/oberon-vm \
-n oberon --create-namespace --set storageClass=<ваш StorageClass>
virtctl -n oberon vnc oberon-vm-wirth
Имейте в виду, что разрешение сторонних обработчиков действует на весь кластер, и любой, кто может создавать виртуалки напрямую, сможет подсунуть в них свой обработчик. На общем кластере это стоит взвесить. И про автоматическое обновление машин из истории выше здесь тоже нужно помнить.
Языковая модель на процессоре Вирта
Помните утверждение из самого первого плана, что новая команда, которая умножает и складывает за один раз, ускорит нейросеть на процессоре Вирта вдвое? Рецензенты его разнесли в пух и прах, но в моём списке отложенных дел после этого осталась строчка с их оценками. Новая команда, по их словам, даст несколько процентов, а быстрый умножитель — прирост больше чем в полтора раза. Звучало это как результат, но за этими числами не было ни программы, ни способа их повторить, только арифметика по таблице тактов. Проверить это руками мне хотелось с самого начала.
Задача получилась такой. Маленькая языковая модель должна работать внутри самой системы Оберон. Программа на Обероне, собранная компилятором самой системы, читает из файла веса модели и печатает текст, и исполняется всё это на настоящей схеме Вирта, такт за тактом. А потом нужно разобраться, куда уходит время, и проверить обе оценки рецензентов.
Памяти у машины Вирта немного, один мегабайт на всё, включая экран, так что модель получилась совсем крошечной. Она предсказывает следующую букву по восьми предыдущим, и в ней около сорока трёх тысяч параметров. Для сравнения, у моделей, с которыми мы разговариваем в чате, параметров в миллионы раз больше. Но даже такая модель занимает почти половину всей свободной памяти машины. Обучал я её на обычном ноутбуке за несколько десятков секунд, на тексте «Алисы в Стране чудес», который давно перешёл в общественное достояние.
Почему не трансформер и почему не целые числа
Современные модели устроены как трансформеры, и такую модель тоже можно было бы написать, но она делает ту же самую основную арифметику, а сверху требует ещё несколько сотен строк сложной математики на нестандартных дробных числах Вирта. Для вопроса, сколько стоит умножение, это ничего бы не добавило.
А перевод модели на целые числа, который обычно ускоряет вычисления на слабом железе, на процессоре Вирта ничего бы не дал. У Вирта умножение целых чисел работает даже медленнее, чем умножение дробных, так что целые числа только добавили бы работы. Это решалось арифметикой, а не модой.
Вот что модель пишет, если начать с alice was:
alice was one thought all the tell you spo
Шекспир отдыхает. Но нас тут интересует не литература, а секундомер.
Главное требование было такое, чтобы текст, который модель печатает на схеме Вирта, совпадал байт в байт с эталоном, посчитанным на обычном компьютере. Для этого эталон пришлось считать не обычными средствами Python, а в точности той же арифметикой, что у процессора Вирта, повторяя каждую операцию в том же порядке. Иначе ничего бы не совпало. Я отдельно посчитал, насколько арифметика Вирта отличается от стандартной, и оказалось, что почти все промежуточные числа отличаются в последних знаках. Сгенерированный текст при этом ни разу не разошёлся, но это просто везение. Сверка с обычным Python была бы почти всегда правильной, а однажды необъяснимо неправильной, и это худший вид ошибки, потому что её невозможно ни воспроизвести, ни объяснить.
Текст совпал везде. И на эмуляторе, где эта проверка теперь запускается автоматически при каждом изменении, и на схеме Вирта, и на схеме с быстрым умножителем, о котором ниже. И внутри настоящей системы с окнами. На диск кладутся программа и веса, два щелчка средней кнопкой сначала компилируют программу, а потом запускают генерацию, а готовый текст снимается с диска и совпадает с эталоном. В QEMU то же самое.
Куда уходит время
На процессоре Вирта с его родным умножителем модель печатает примерно девять букв в секунду. На живой машине, где видеоконтроллер отбирает у процессора немного времени, чуть меньше. В общем, девять букв в секунду, если повезёт.
Когда я посмотрел, чем процессор занят всё это время, картина получилась очень ясной. Почти сорок процентов всех тактов уходит на умножение дробных чисел, а бо́льшая часть этого времени — просто ожидание, пока медленный умножитель досчитает. Он у Вирта последовательный и вычисляет произведение по одному биту за такт, так что одно умножение занимает двадцать шесть тактов. Ещё четверть времени уходит на чтение из памяти. И тут выяснилась ещё одна интересная вещь. Самую простую строчку программы, где к сумме прибавляется произведение, компилятор Вирта превращает в двадцать семь команд, из которых полезных только четыре. Всё остальное — это чтение и запись суммы в память, счётчик цикла и, да, опять проверки границ и проверки на NIL.
Дело в том, что компилятор Вирта не умеет держать переменные в регистрах процессора и каждый раз ходит за ними в память. Один из рецензентов предсказал это по исходникам ещё в первый день и почти точно угадал число тактов. Это, кстати, не недосмотр, а сознательная простота, ведь весь компилятор Вирта занимает меньше трёх тысяч строк и собирает сам себя за секунды, поэтому сложные оптимизации в эту смету просто не влезли.
Быстрый умножитель
Раз всё упирается в умножитель, я написал быстрый умножитель. Он делает то же самое, что умножитель Вирта, но не по одному биту за такт, а сразу целиком, за один или два такта, при этом округление и прочие тонкости реализованы по Вирту. Включается он так же, как команда CHK, одним переключателем при сборке процессора.
Главное требование к нему было такое же строгое, как ко всему остальному. Результаты должны совпадать с умножителем Вирта до последнего бита. Я прогнал десятки миллионов пар чисел, включая все неудобные граничные случаи, и расхождений не нашлось ни одного. А чтобы убедиться, что проверка вообще умеет замечать ошибки, я нарочно испортил округление в одном месте, и она нашла десятки тысяч расхождений.
С быстрым умножителем модель стала работать в 1,6 раза быстрее, около четырнадцати букв в секунду вместо девяти. Расчёт по профилю предсказывал ровно столько же, но на простой машине без кэшей это скорее проверка того, что профиль был посчитан правильно, чем настоящее предсказание. Даже если бы умножение стало совсем бесплатным, больше чем в 1,64 раза ускорить программу было бы нельзя, потому что остальные такты никуда не деваются. Так что оценка рецензентов по числу не совсем сбылась, но в главном они были правы, тормозил именно умножитель.
А вот для обычной работы системы быстрый умножитель не даёт вообще ничего. Компилятор, который собирает сам себя, за сорок миллионов команд делает всего тридцать одно умножение дробных чисел, а загрузка системы — ни одного. Медленный умножитель был совершенно разумным решением для машины, на которой пишут текст и собирают программы. Языковая модель оказалась первой задачей, для которой он стал узким местом.
А что же новая команда, которая умножает и складывает, с которой всё началось? Реализовывать её я не стал, а оценил по счётчикам. На обычном умножителе Вирта она дала бы от полутора до шести процентов, на быстром — до десяти. Так что главный рычаг здесь не новая команда, а компилятор, который научится держать сумму в регистре, а не бегать за ней в память. Но это я уже не мерил.
В железе быстрый умножитель оказался даже меньше родного, потому что на ПЛИС есть готовые аппаратные блоки умножения, а вся логика последовательного подсчёта просто исчезла. Правда, однотактный вариант заметно снижает предельную частоту процессора, а двухтактный почти так же быстр и частоту не трогает. Родные 25 МГц при этом держатся с большим запасом в обоих случаях, так что выбирать между ними придётся, только если кто-нибудь захочет разогнать машину Вирта.
Две находки по дороге
Первая находка касается сравнения дробных чисел. Компилятор Вирта сравнивает их так же, как целые, через вычитание и проверку флагов процессора, и для некоторых сравнений использует флаг переполнения. Только этот флаг меняют исключительно операции с целыми числами. В итоге, если перед сравнением дробных чисел где-то в программе переполнилось целое число, сравнение может дать неверный ответ. Я проверил это совсем простой программой. Сначала она честно говорит, что единица меньше двух, потом переполняет целое число, после чего так же уверенно сообщает, что единица не меньше двух. Это воспроизводится и на эмуляторе, и на схеме. В моей модели переполнений нет, и эталон это проверяет. Новизны тут, скорее всего, нет: наверняка кто-то это уже видел и пробовал, так что поправьте, если знаете.
Вторая находка оказалась смешнее. Модель должна была сохранить на диск файл с текстом, и тут выяснилось, что в наш QEMU за всё время проекта ни разу никто ничего не записывал, потому что загрузка системы только читает с диска. А записывать он, как оказалось, не умел, потому что диск был подключён без права записи, и первая же попытка что-нибудь сохранить роняла QEMU напрочь. К тому же файловая система Оберона кладёт новые данные за концом диска, а образ диска был ровно того размера, который занимала система, так что файл ещё и молча не сохранялся.
Когда я это исправил и запустил машину в Kubernetes, запись всё равно не работала, теперь уже из-за прав на файл. Задача, которая кладёт диск машины на её том, должна была разрешить запись, но делала это способом, который на самом деле ничего не разрешал. Всё это время диск в кластере был доступен только для чтения, и если бы кто-нибудь сохранил файл внутри Оберона, машина бы упала. Никто этого не заметил, потому что никто ничего не сохранял. Теперь права ставятся явно, в том числе на уже созданных дисках, а сценарий проверки в песочнице спрашивает у самого QEMU, открыл ли он диск на запись.
Все подробности, таблицы и команды для повтора лежат в описании эпизода, а главное можно повторить за пару минут командой cd impl && make lm-check && make lm-profile.
Как всё это поставить себе
Способов четыре, от совсем простого, где ничего не нужно ставить, до своего облака.
Проще всего открыть лабораторию в браузере и пройти хотя бы первые четыре задания. Вы увидите, как работает интерфейс, где любой текст может быть командой, напишете свой первый модуль и сломаете машину, записав мусор прямо в память экрана. Если задания не нужны, на странице Просто запустить систему лежит голая система, а на странице Поменять процессор можно своими глазами посмотреть на цену проверки. Среднюю кнопку мыши изображает щелчок с Alt, а комбинацию из двух кнопок — щелчок с Shift. Из того, что стоит попробовать самому, я бы посоветовал средний щелчок по System.ShowModules, чтобы увидеть, как мало модулей нужно системе после загрузки, и по Hilbert.Draw, потому что это просто красиво. А потом лабораторные двенадцать и тринадцать, самые железные: в одной вы сами мерите три варианта процессора, в другой добавляете в язык новую встроенную процедуру и пересобираете компилятор прямо внутри системы.
Если хочется машину Вирта как обычную виртуалку, соберите свой QEMU по командам из раздела про QEMU или по инструкции. Там интересно попробовать варианты процессора с аппаратной проверкой и с дескрипторами.
Если у вас есть свой KubeVirt, есть инструкция по-русски и краткая версия в спойлере выше. Поддерживаются две последние версии KubeVirt и серверы как на Intel, так и на ARM. Начать можно с одноразового тестового кластера, тот же сценарий, что гоняет автоматическая проверка, запускается руками. И ещё раз повторю предупреждение, потому что сам на этом погорел: служебный образ меняется для всех виртуалок кластера сразу, так что на рабочем кластере сначала посмотрите, включено ли автоматическое обновление машин.
А если у вас Cozystack, достаточно подключить каталог командами из раздела про Cozystack, и у пользователей появятся машина Вирта, лаборатория, методичка и набор, который ставит всё сразу. Подробности лежат на странице проекта. Если нужно показать студентам или коллегам, как устроен компьютер целиком, это, пожалуй, самый быстрый способ. Про автоматическое обновление машин компонент теперь спросит сам и без явного согласия ничего трогать не будет.
И если хочется проверить нас самих, команда cd impl && make deps && make check примерно за восемь минут прогонит все тесты процессора, загрузку системы, пошаговую сверку с эталоном, самосборку компилятора и пересборку всей системы с побайтовым сравнением. Для этого нужны Verilator и компилятор C++.
Что я из этого вынес
Первое — про проверку границ. Она дешевле, чем принято думать, но совсем бесплатной я бы её не назвал. На процессоре Вирта, очень простом, без кэшей и без угадывания переходов, она стоила компилятору от двух до пяти процентов времени, в зависимости от того, из какой части системы её убрать. Причём большую часть этой цены дают проверки на пустой указатель, а не на границы массивов. На больших современных процессорах в моём нарочно удобном цикле её вообще не видно, потому что лишние команды попадают в свободные места, а на серверном ARM она видна, хоть и слабо. В настоящих программах проверки дороги в первую очередь тем, что мешают компилятору обрабатывать массивы пачками, и вот это я уже не замерял. А CHERI прячет проверку прямо внутрь чтения из памяти, и тогда в программе её нет вовсе.
Второе — узкое место часто оказывается не там, где его ищут. Для нейросети все, включая меня, хотели новую хитрую команду, а тормозил старый медленный умножитель. Его замена ускорила модель в 1,6 раза, а новая команда по оценке дала бы несколько процентов. Рецензенты сказали это в первый же день, а замер через неделю это подтвердил. Сначала нужно измерить, и только потом добавлять железо.
Третье — указатель, который знает свои границы, приходится делать шире обычного адреса. Мои дескрипторы, уложенные в обычное слово, упёрлись в мегабайт памяти и массивы до четырёх тысяч элементов, и именно поэтому в CHERI указатели вдвое шире.
Четвёртое — самые неприятные ошибки находит только живая среда. Тесты у меня были хорошие, но самое интересное нашли настоящий KubeVirt, настоящий Cozystack и живой сервер на ARM: от машины, которой не нужно управление питанием, до сетевого слоя, который молча не отвечает, и шестнадцати секунд до переезда всего кластера. С тех пор я выпускаю новую версию, только если она сначала прошла полную проверку в песочнице.
И пятое — нейросеть пишет код быстро, а доказывает, что он работает, медленно. Девять дней на всё это получились только потому, что код писали агенты (хотя я бы сам вообще никогда его не написал руками). Но значительная часть этого времени ушла на то, чтобы проверки научились честно краснеть, и на каждое красивое число находился аудитор, тоже агент, который его разбирал. Если проверка ни разу не покраснела, скорее всего, она ничего не проверяет, особенно когда очень хочется, чтобы всё получилось. А отдельный открытый вопрос в том, что делать с таким кодом в открытых проектах. QEMU и libvirt его не принимают вовсе, CNCF, наоборот, относится спокойно, и как с этим жить опенсорсу дальше, пока не очень понятно.
Вместо итога
Весь проект открыт. Исходники лежат в репозитории, наш код под лицензией Apache-2.0, а процессор для QEMU под GPL, как и сам QEMU. Сайт с лабораторией — tym83.github.io/paleocomputing. Все находки с командами, которыми их можно повторить, лежат в репозитории в папке impl/docs, а про сам Cozystack можно почитать на cozystack.io.
В следующей статье цикла мы с Клодом реализуем альтернативную Аду, то есть язык, спецификация которого участвовала в конкурсе Минобороны США на создание языка программирования, дошла до финала, но уступила там спейификации, из которой появилась Ада. Далее в серии будут Burroughs B5000 с его аппаратной защитой памяти, Lilith, самая первая машина Вирта, для которой понадобится только новый паспорт, и, если хватит терпения и усидчивости, своя плата с Обероном на ПЛИС, чтобы наконец померить такты не в симуляции, а на настоящем железе (а если с ПЛИС всё получится, хотел бы заморочиться и заказать настоящий готовый кремнивевый кристалл, благо, есть компании, которые делают процессоры на заказ).
Если вы используете Оберон в обучении или просто когда-то на нём писали, напишите в комментариях, мне очень интересно, где у вас он живёт сейчас. Также буду рад любым замечаниям и исправлениям.
Опрос: что вы сделаете после этой статьи?
Открою лабораторию и сломаю машину, записав мусор в память экрана
Пойду проверять автообновление виртуалок на своём кластере. Прямо сейчас
Перепишу всё на Обероне (Go — это же почти Оберон, только с горутинами)
Буду и дальше выключать проверку границ ради пары процентов
Я на нём писал в девяностых, и мне есть что сказать в комментариях
Дочитал до опроса, это уже подвиг
P.S. Отдельное спасибо Никлаусу Вирту. Я никогда его не видел, но девять дней разговаривал с его схемой, и она почти ни разу мне не соврала. В отличие от моих проверок.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.