Rosetta 2 работает удивительно быстро по сравнению с другими эмуляторами x86-on-ARM. Из праздного любопытства я решил потратить немного времени на изучение его работы и обнаружил довольно много интересных и необычных вещей, в связи с чем решил собрать воедино свои заметки.
Мое понимание, несколько приблизительное, в основном, базируется на чтении заблаговременно преобразованного кода (ahead-of-time translated) и выводах о времени выполнения. Если у вас возникнут какие-либо замечания или вы хотите поделиться полезными приемами, которые я пропустил, то буду рад обратной связи.
Скриншот IDA Pro: сравнение AOT-кода Rosetta 2 и исходного кода x86 функции с неправильным названием «parity64», которая вычисляет 8-битный аргумент четности с использованием флага четности x86.
AOT-преобразование (Ahead-of-time translation)
Rosetta 2 заранее переводит весь текстовый сегмент двоичного файла с x86 на ARM. Она также поддерживает динамическую компиляцию (JIT), но она используется относительно редко, что позволяет избежать как прямых затрат времени на компиляцию, так и косвенных последствий кэширования инструкций и данных.
Другие интерпретаторы обычно переводят код в порядке выполнения, что позволяет сократить время запуска, но не сохраняет расположение кода.
OTO-преобразование
[ Исправление: в более ранней версии этого поста говорилось, что каждая заблаговременно преобразованная инструкция является допустимой точкой входа. Хотя я по-прежнему считаю, что можно было бы перейти почти к любой заблаговременно преобразованной инструкции, но используемые таблицы поиска не позволяют этого. Я полагаю, что данная оптимизация нужна для уменьшения размера поиска. Оптимизация пролога/эпилога также была найдена после публикации первоначальной версии этого поста.]
Каждая инструкция x86 преобразуется в одну или несколько инструкций ARM один раз в AOT-двоичном файле (за исключением NOP, которые игнорируются). Когда косвенный переход или вызов устанавливают указатель инструкции на произвольное смещение в текстовом сегменте, среда выполнения будет искать соответствующую переведенную инструкцию и переходить к ней.
При этом используется таблица поиска x86 для ARM, которая содержит все запуски функций и другие базовые блоки, на которые нет ссылок. В случае пропусков, например, при обработке оператора switch, происходит возврат к JIT.
Чтобы обеспечить точную обработку исключений, профилирование выборки и подключение отладчиков, Rosetta 2 поддерживает полное сопоставление преобразованных инструкций ARM с их исходным адресом x86 и гарантирует, что состояние между каждой инструкцией не будет зависеть от конечной реализации.
Это почти полностью предотвращает оптимизацию между инструкциями. Есть два известных исключения. Во-первых, это оптимизация «неиспользованных флагов», которая позволяет избежать вычисления значения флагов x86, если они не используются перед перезаписью на каждом пути из инструкции установки флага. Другой объединяет в себе прологи и эпилоги функций, комбинируя инструкции push и pop и задерживая обновления указателя стека. В сопоставлении адресов ARM и x86 они выглядят так, как если бы они были одной инструкцией.
Существуют некоторые компромиссы для сохранения канонического состояния каждой инструкцией:
- Либо все значения эмулируемых регистров должны храниться в регистрах хоста, либо вам необходимо загружать или сохранять инструкции каждый раз, когда используются определенные регистры. 64-битный x86 имеет вдвое меньше регистров, чем 64-битный ARM, так что это не проблема для Rosetta 2, но это было бы существенным недостатком техники для эмуляции 64-битного ARM на x86 или PPC на 64-битном ARM.
- Существует очень мало межинструкционных оптимизаций, что в некоторых случаях приводит к удивительно плохой генерации кода. Однако, как правило, код уже сгенерирован оптимизирующим компилятором для x86, поэтому преимущества многих оптимизаций будут ограничены.
Однако есть существенные преимущества:
- Как правило, перевод каждой инструкции только один раз имеет значительные преимущества в кэше инструкций — другие эмуляторы обычно не могут повторно использовать код при переходе к новой цели.
- Точные исключения без необходимости хранить гораздо больше информации, чем границы инструкций.
- Отладчик (LLDB) работает с бинарными файлами x86 и может подключаться к процессам Rosetta 2.
- Уменьшение количества оптимизаций позволяет упростить генерацию кода и ускорить преобразование. Скорость трансляции важна как для времени первого запуска (где могут быть преобразованы десятки мегабайт кода), так и для времени JIT-трансляции, которое имеет решающее значение для производительности приложений, использующих JIT-компиляторы.
Оптимизация для кэша инструкций может показаться не столь значительным преимуществом, но обычно она есть в эмуляторах, поскольку при переводе между наборами инструкций уже существует коэффициент расширения. Каждая однобайтовая x86-загрузка становится четырехбайтовой ARM-инструкцией, а каждая x86-инструкция чтения-модификации-записи — тремя ARM-инструкциями (или более, в зависимости от режима адресации). И это при наличии идеальной инструкции. Когда инструкции имеют немного другую семантику, требуется еще больше инструкций, чтобы получить требуемое поведение.
Учитывая эти ограничения, цель обычно состоит в том, чтобы максимально приблизиться к одной ARM-инструкции на каждую x86-инструкцию, и приемы, описанные в следующих разделах, позволяют Rosetta достигать этого на удивление часто. Это позволяет максимально снизить коэффициент расширения. Например, коэффициент расширения размера инструкции для двоичного файла sqlite3 составляет ~ 1,64x (1,05 МБ инструкций x86 против 1,72 МБ инструкций ARM).
(Два поиска (один из x86 в ARM, а другой из ARM в x86) находятся через список фрагментов, найденный в LC_AOT_METADATA. Целевые результаты ветки кэшируются в хэш-карте. Для них могут использоваться различные структуры, но в одном бинарном коде критически важное по производительности распределение x86 на ARM использовало двухуровневый двоичный поиск, а гораздо более крупное, менее критически важное по производительности, распределение ARM на x86 использовало двоичный поиск верхнего уровня с последующим линейным сканированием битовых пакетов данных.
Схема памяти
Инструкция ADRP, за которой следует ADD, используется для эмуляции адресации x86 относительно RIP. Диапазон ограничен и составляет +/- 1 ГБ. Rosetta 2 помещает переведенный двоичный файл в память после непреобразованного двоичного файла, поэтому у вас примерно есть [непреобразованный код][данные][преобразованный код][код динамической поддержки]. Это означает, что ADRP может ссылаться на данные и непреобразованный код по мере необходимости. Загрузка функций поддержки среды выполнения сразу после преобразованного кода также позволяет переведенному коду делать прямые вызовы в среду выполнения.
Прогноз адреса возврата
Все высокопроизводительные процессоры имеют стек адресов возврата, позволяющий прогнозировать ветвления для правильного предсказания инструкций возврата.
Rosetta 2 использует это преимущество, переписывая инструкции x86 CALL и RET в инструкции ARM BL и RET (а также архитектурные загрузки/сохранения и настройки указателя стека). Это также требует некоторого дополнительного учета, сохранения ожидаемого адреса возврата x86 и соответствующей переведенной цели перехода в специальном стеке при вызове и проверке их при возврате, но это позволяет правильно прогнозировать возврат.
Этот трюк также используется в эмуляторе GameCube/Wii Dolphin.
Расширения для управления флагами ARM
Много накладных расходов возникает из-за небольших различий в поведении между x86 и ARM, таких, как, например, семантика флагов. Rosetta 2 использует расширения ARM для управления флагами (FEAT_FlagM и FEAT_FlagM2) для эффективной обработки этих различий.
Например, x86 использует «вычитание с заимствованием», тогда как ARM использует «вычитание с переносом». Это эффективно инвертирует флаг переноса при выполнении вычитания, а не при выполнении сложения. Поскольку CMP представляет собой вычитание с установкой флага без результата, гораздо чаще используются флаги вычитания, чем сложения, поэтому Rosetta 2 выбирает инвертированную форму в качестве канонической формы флага переноса. Инструкция CFINV (carry-flag-invert) используется для инвертирования переноса после любой операции ADD, где флаг переноса используется или может сбрасываться (и для исправления флага переноса, когда он является входом для инструкции «сложение с переносом»).
Инструкции сдвига x86 также требуют сложной обработки флагов, поскольку они сдвигают биты во флаг переноса. Инструкция RMIF (rotate-mask-insert-flags) используется в rosetta для перемещения произвольного бита из регистра в произвольный флаг, что делает эмуляцию фиксированных сдвигов (среди прочего) относительно эффективной. Переменные сдвиги остаются относительно неэффективными, если флаги исчезают, поскольку флаги не должны изменяться при сдвиге на ноль, что требует условного перехода.
В отличие от x86, ARM не поддерживает 8-битные или 16-битные операции. Как правило, их легко эмулировать с помощью более широких операций (именно так компиляторы реализуют операции над этими значениями), с небольшой загвоздкой в том, что x86 требует сохранения исходных старших битов. Однако инструкции SETF8 и SETF16 помогают эмулировать поведение установки флагов более ограниченных инструкций.
Все они были из FEAT_FlagM. Инструкции из FEAT_FlagM2 — это AXFLAG и XAFLAG, которые преобразуют флаги условий с плавающей запятой в/из таинственного «внешнего формата». По какому-то странному совпадению, это формат x86, поэтому эти инструкции используются при работе с флагами с плавающей запятой.
Обработка с плавающей запятой
И x86, и ARM реализуют IEEE-754, поэтому наиболее распространенные операции с плавающей запятой практически идентичны. Одним из исключений является обработка различных возможных битовых шаблонов, лежащих в основе значений NaN, а другим является обнаружение крайне малых значений до или после округления. Большинство приложений не покажут ошибку, если вы сделаете неправильно, но некоторые покажут, и, чтобы сделать все правильно, потребуются «дорогостоящие» проверки каждой операции с плавающей запятой. К счастью, это решается благодаря аппаратному обеспечению.
Существует стандартное расширение ARM для альтернативного поведения с плавающей запятой (FEAT_AFP) из ARMv8.7, но дизайн M1 предшествует стандарту v8.7, поэтому Rosetta 2 использует нестандартную реализацию.
(Какое совпадение — «альтернатива» точно соответствует x86. Довольно забавно, что ARM будет указывать «Javascript» в описании инструкции, но для «x86» нужны два разных эвфемизма.)
Строгий порядок записи (TSO)
Популярное нестандартное программное расширение ARM, доступное в Apple M1, — это аппаратная поддержка TSO (total-store-ordering), которая при включении дает обычным инструкциям ARM по загрузке и сохранению те же гарантии упорядочивания, что и в системе x86.
Насколько я знаю, это не часть стандарта ARM, но и не специфично для Apple: Nvidia Denver/Carmel и Fujitsu A64fx — это другие примеры 64-битные процессоры ARM, которые также реализуют TSO (спасибо marcan за это уточнение).
Секретное расширение Apple
Существует всего несколько различных инструкций, на которые приходится 90% всех выполняемых операций, и в верхней части этого списка находятся сложение и вычитание. В ARM они могут дополнительно устанавливать четырехбитный регистр NZVC, тогда как на x86 они всегда устанавливают шесть битов флага: CF, ZF, SF и OF (что достаточно хорошо соответствует NZVC), а также PF (флаг четности) и AF (флаг настройки).
Эмуляция последних двух в программном обеспечении возможна (и, кажется, поддерживается Rosetta 2 для Linux), но может быть довольно затратной. Большинство программ не заметят, если вы выполните ее неправильно, но некоторые - заметят. Apple M1 имеет недокументированное расширение, которое при включении гарантирует, что такие инструкции, как ADDS, SUBS и CMP, вычисляют PF и AF и сохраняют их как биты 26 и 27 NZCV соответственно, обеспечивая точную эмуляцию без потери производительности.
Быстрое аппаратное обеспечение
В конечном счете, M1 невероятно быстр. Будучи намного шире, чем сопоставимые процессоры x86, он обладает замечательной способностью избегать ограничения пропускной способности, даже со всеми дополнительными инструкциями, которые генерирует Rosetta 2. В некоторых случаях (iirc, IDA Pro) действительно не наблюдается прироста скорости при переходе от Rosetta 2 к родному ARM.
Вывод
Я считаю, что в Rosetta 2 есть значительные возможности для повышения производительности за счет использования статического анализа для поиска возможных целей ветвления и выполнения межинструкционной оптимизации между ними. Однако это будет достигнуто за счет значительного увеличения сложности (особенно для отладки), увеличения времени трансляции и менее предсказуемой производительности (поскольку придется возвращаться к JIT, когда статический анализ неверен).
Инженерия заключается в том, чтобы найти правильный компромисс, и я бы сказал, что Rosetta 2 сделала именно это. В то время как другим эмуляторам может потребоваться оптимизация между инструкциями для повышения производительности, Rosetta 2 может доверять быстрому процессору, генерировать код, который учитывает его кэши и предикторы, и решать самые сложные аппаратные проблемы.
Вы можете подписаться на меня в @dougall@mastodon.social.
Обновление: поддержка SSE2
Прочитав некоторые комментарии, я понял, что допустил значительное упущение в исходном посте. Rosetta 2 обеспечивает полную эмуляцию набора инструкций SSE2 SIMD. Эти инструкции были включены в компиляторы по умолчанию довольно долгое время, это было необходимо для совместимости. Однако все общие операции преобразуются в разумно оптимизированную последовательность операций NEON. Это крайне важно для производительности программного обеспечения, оптимизированного для использования этих инструкций.
Многие эмуляторы также используют данный подход преобразования SIMD в SIMD, но другие используют SIMD для скалярного преобразования или вызывают функции поддержки времени выполнения для каждой операции SIMD.
Обновление: оптимизация неиспользуемых флагов
Это одна из двух межинструкционных оптимизаций, упомянутых ранее, но она заслуживает отдельного раздела. Rosetta 2 избегает вычислений флагов, когда они не используются и не исчезают. Это означает, что даже с помощью инструкций по манипулированию флагами подавляющее большинство инструкций x86 с установкой флагов можно преобразовать в инструкции ARM без установки флагов и без каких-либо исправлений. Это значительно увеличивает количество и размер инструкций.
Это еще более ценно для Rosetta 2 на виртуальных машинах Linux. На виртуальных машинах Rosetta не может включить расширение флага четности Apple и вместо этого вычисляет значение «вручную». (Может также вычислять или не вычислять флаг настройки). Процедура относительно затратная, поэтому очень важно избегать ее.
Обновление: сочетание вводной (пролога) и завершающей (эпилога) частей кода программы
Последняя оптимизация между инструкциями, о которой я знаю, - это объединение пролога и эпилога. Rosetta 2 находит группы инструкций, которые устанавливают или удаляют кадры стека, и объединяет их, объединяя загрузки и сохранения и откладывая обновления указателя стека. Это эквивалентно «движку стека», используемому в аппаратных реализациях x86.
Например, следующий пролог:
push rbp
mov rbp, rsp
push rbx
push rax
Становится:
stur x5, [x4,#-8]
sub x5, x4, #8
stp x0, x3, [x4,#-0x18]!
Это значительно сокращает количество загрузок, сохранений и арифметических инструкций, которые изменяют указатель стека, повышая производительность и размер кода. Эти парные загрузки и сохранения выполняются как одна операция на Apple M1, и, насколько мне известно, это невозможно на процессорах x86, что дает Rosetta 2 преимущество.
Приложение: Метод исследования
Это исследование было основано на методах и информации, описанных Ко М. Накагава в превосходном Project Champollion.
Чтобы увидеть заранее переведенный код Rosetta, мне пришлось отключить SIP, скомпилировать новый двоичный файл x86, дать ему уникальное имя, запустить его, а затем запустить otool -tv /var/db/oah/*/* /unique-name.aot (или используйте свой любимый инструмент — это просто бинарный файл Mach-O). Это было сделано в старой версии macOS, поэтому с тех пор все могло измениться и улучшиться.
Обновление: Приложение: Совместимость
Хотя это не имеет ничего общего с тем, почему Rosetta 2 работает быстро, есть пара впечатляющих функций совместимости, о которых стоит упомянуть.
Rosetta 2 имеет полную, медленную программную реализацию 80-битных чисел с плавающей запятой x87. Это позволяет программному обеспечению, использующему эти инструкции, работать правильно. Windows на Arm приближается к этому, используя 64-битные операции с плавающей запятой, которые обычно работают, но в редких случаях снижение точности вызывает проблемы. Большая часть программного обеспечения либо не использует x87, либо была разработана для работы на более старом оборудовании, поэтому, хотя данная эмуляция медленная, но все же обычно работает.
[ Обновление: в более ранней версии этого поста говорилось, что я не верю, что Windows на Arm поддерживает x87. Спасибо kode54 за исправление в комментарии.]
Rosetta 2 также поддерживает полный набор 32-битных инструкций для Wine. Поддержка собственных 32-разрядных приложений macOS была прекращена до запуска Apple Silicon, но поддержка 32-разрядного набора инструкций x86 якобы продолжает существовать. (Сам я не проверял это.)