Как я нашёл баг в процессорах Intel Skylake

перевод
m1rko 6 июля в 15:24 40,9k
Оригинал: Xavier Leroy
Инструкторы курсов «Введение в программирование» знают, что студенты находят любые причины для ошибок своих программ. Процедура сортировки отбраковала половину данных? «Это может быть вирус в Windows!» Двоичный поиск ни разу не сработал? «Компилятор Java сегодня странно себя ведёт!» Опытные программисты очень хорошо знают, что баг обычно в их собственном коде, иногда в сторонних библиотеках, очень редко в системных библиотеках, крайне редко в компиляторе и никогда — в процессоре. Я тоже так думал до недавнего времени. Пока не столкнулся с багом в процессорах Intel Skylake, когда занимался отладкой таинственных сбоев OCaml.

Первое проявление


В конце апреля 2016 года вскоре после выпуска OCaml 4.03.0 один Очень Серьёзный Индустриальный Пользователь OCaml (ОСИП) обратился ко мне в частном порядке с плохими новостями: одно из наших приложений, написанное на OCaml и скомпилированное в OCaml 4.03.0, падало случайным образом. Не при каждом запуске, но иногда вылетал segfault, в разных местах кода. Более того, сбои наблюдались только на их самых новых компьютерах, которые работали на процессорах Intel Skylake (Skylake — это кодовое название последнего на тот момент поколения процессоров Intel. Сейчас последним поколением является Kaby Lake).

За последние 25 лет мне сообщали о многих багах OCaml, но это сообщение вызывало особенное беспокойство. Почему только процессоры Skylake? В конце концов, я даже не мог воспроизвести сбои в бинарниках ОСИПа на компьютерах в моей компании Inria, потому что все они работали на более старых процессорах Intel. Почему сбои не воспроизводятся? Однопоточное приложение ОСИПа делает сетевые и дисковые операции I/O, так что его выполнение должно быть строго детерминировано, и любой баг, который вызвал segfault, должен проявлять себя при каждом запуске в том же месте кода.

Моим первым предположением было то, что у ОСИПа глючит железо: плохая микросхема памяти? перегрев? По моему опыту, из-за таких неисправностей компьютер может нормально загружаться и работать в GUI, но падает под нагрузкой. Итак, я посоветовал ОСИПу запустить проверку памяти, снизить тактовую частоту процессора и отключить Hyper-Threading. Предположение насчёт HT появилось в связи с недавним сообщением о баге в Skylake с векторной арифметикой AVX, который проявлялся только при включенном HT (см. описание).

ОСИПу не понравились мои советы. Он возразил (логично), что они запускали другие требовательные к CPU и памяти задачи/тесты, но падают только программы, написанные на OCaml. Очевидно, они решили, что их железо в порядке, а баг в моей программе. Ну отлично. Я всё-таки уговорил их запустить тест памяти, который не выявил ошибок, но мою просьбу выключить HT они проигнорировали. (Очень плохо, потому что это сэкономило бы нам кучу времени).

Одновременно ОСИП провёл впечатляющее расследование с использованием разных версий OCaml, разных компиляторов C, которые используются для компиляции системы поддержки выполнения OCaml, и разных операционных систем. Вердикт был следующий. Глючит OCaml 4.03, включая ранние беты, но не 4.02.3. Из компиляторов глючит GCC, но не Clang. Из операционных систем — Linux и Windows, но не MacOS. Поскольку в MacOS используется Clang и там работает порт с Windows-версии на GCC, то причиной чётко назвали OCaml 4.03 и GCC.

Конечно, ОСИП рассуждал логично: мол, в системе поддержки выполнения OCaml 4.03 был фрагмент плохого кода С — с неопределённым поведением, как мы говорим в бизнесе — из-за которого GCC генерировал сбойный машинный код, поскольку компиляторам C позволено работать при наличии неопределённого поведения. Это не первый раз, когда GCC максимально некорректно обрабатывает неопределённое поведение. Например, см. эту уязвимость в безопасности или этот сломанный бенчмарк.

Такое объяснение казалось вполне правдоподобным, но оно не объясняло случайный характер сбоев. GCC генерирует причудливый код из-за неопределённого поведения, но это по-прежнему детерминистический код. Единственной причиной случайности, которую я смог придумать, могла быть Address Space Layout Randomization (ASLR) — функция ОС для рандомизации адресного пространства, которая изменяет абсолютные адреса в памяти при каждом запуске. Система поддержки выполнения OCaml кое-где использует абсолютные адреса, в том числе для индексации страниц памяти в хеш-таблицу. Но сбои оставались случайными даже после отключения ASLR, в частности, во время работы отладчика GDB.

Наступил май 2016 года, и пришла моя очередь замарать руки, когда ОСИП прислал тонкий намёк — дал доступ в шелл к своей знаменитой машине Skylake. Первым делом я собрал отладочную версию OCaml 4.03 (к которой позже планировал добавить больше отладочного инструментария) и собрал заново приложение ОСИПа с этой версией OCaml. К сожалению, эта отладочная версия не вызывала сбой. Вместо этого я начал работать с исполняемым файлом от ОСИПа, сначала интерактивно вручную под GDB (но это сводило меня с ума, потому что иногда приходилось ждать сбоя целый час), а затем с небольшим скриптом OCaml, который запускал программу 1000 раз и сохранял дампы памяти на каждом сбое.

Отладка системы поддержки выполнения OCaml — не самое весёлое занятие, но посмертная отладка из дампов памяти вообще ужасна. Анализ 30 дампов памяти показал ошибки segfault в семи разных местах, два места в OCaml GC, а ещё пять в приложении. Самым популярным местом с 50% сбоев была функция mark_slice в сборщике мусора OCaml. Во всех случаях у OCaml была повреждена куча: в хорошо сформированной структуре данных находился плохой указатель, то есть указатель, который указывал не на первое поле блока Caml, а на заголовок или на середину блока Caml, или даже на недействительный адрес памяти (уже освобождённой). Все 15 сбоев mark_slice были вызваны указателем на два слова впереди блока размером 4.

Все эти симптомы согласовались со знакомыми ошибками, вроде той, что компилятор mark_slice забывал зарегистрировать объект памяти в сборщике мусора. Однако такие ошибки привели бы к воспроизводимым сбоям, которые зависят только от распределения памяти и действий сборщика мусора. Я совершенно не понимал, какой тип ошибки управления памятью OCaml мог вызвать случайные сбои!

За неимением лучших идей, я опять прислушался к внутреннему голосу, который шептал: «аппаратный баг!». У меня было неясное ощущение, что сбои чаще случаются, если машина находится под большей нагрузкой, как будто это просто перегрев. Для проверки этой теории я изменил свой скрипт OCaml для параллельного запуска N копий программы ОСИПа. Для некоторых прогонов я также отключал уплотнитель памяти OCaml, что вызывало большее потреблением памяти и большую активность сборщика мусора. Результаты оказались не такими, как я ожидал, но всё равно поразительными:

N Загрузка системы С настройками по умолчанию С отключенным уплотнителем
1 3+epsilon 0 сбоев 0 сбоев
2 4+epsilon 1 сбой 3 сбоя
4 6+epsilon 12 сбоев 19 сбоев
8 10+epsilon 17 сбоев 23 сбоя
16 18+epsilon 16 сбоев



Здесь показано количество сбоев на 1000 запусков тестовой программы. Видите скачок между $N = 2$ и $N = 4$? И плато между более высокими значениями $N$? Чтоб объяснить эти цифры, нужно более подробно рассказать о тестовой машине Skylake. У неё 4 физических ядра и 8 логических ядер, поскольку включен HT. Два ядра были заняты в фоне двумя долговременными тестами (не моими), но в остальном машина была свободна. Следовательно, загрузка системы равнялась $2 + N + epsilon$, где $N$ — это количество тестов, запущенных параллельно.

Когда одновременно работает не более четырёх процессов, планировщик ОС поровну распределяет их между четырьмя ядрами машины и упорно старается не направлять два процесса на два логических ядра одного физического ядра, потому что это приведёт к недостаточному использованию других физических ядер. Такое происходит в случае с $N=1$, а также большую часть времени в случае с $N=2$. Если количество активных процессов превышает 4, то ОС начинает применять HT, назначая процессы двум логическим ядрам на одном и том же физическом ядре. Это случай $N=4$. Только если все 8 логических ядер на машине заняты, ОС осуществляет традиционное разделение времени между процессами. В нашем эксперименте это случаи $N=8$ и $N=16$.

Теперь стало видно, что сбои начинаются только при включении Hyper-Threading, точнее, тогда, когда программа OCaml работала рядом с другим потоком (логическим ядром) на том же физическом ядре процессора.

Я отправил ОСИПу результаты экспериментов, умоляя его принять мою теорию о том, что во всём виновата многопоточность. В этот раз он послушал и отключил HT на своей машине. После этого сбои полностью исчезли: двое суток непрерывного тестирования не выявили вообще ни одной проблемы.

Проблема решена? Да! Счастливый конец? Не совсем. Ни я, ни ОСИП не пытались сообщить о проблеме в Intel или кому-то ещё, потому что ОСИП был удовлетворён тем, что можно компилировать OCaml c Clang, а ещё потому что он не хотел неприятной огласки в духе «продукты ОСИПа падают случайным образом!». Я же совсем устал от этой проблемы, да и не знал, как сообщать о таких вещах (в Intel нет публичного баг-трекера, как у обычных людей), а ещё я подозревал, что это баг конкретных машин ОСИПа (например, партия сбойных микросхем, которая случайно попала не в ту корзину на фабрике).

Второе проявление


2016-й год прошёл спокойно, больше никто не сообщал, что небо (sky, точнее, Skylake — каламбур) падает из-за OCaml 4.03, так что я с радостью забыл об этом маленьком эпизоде с ОСИПом (и продолжил сочинять ужасные каламбуры).

Затем, 6 января 2017 года Ангерран Декорн и Джорис Джованнанджели из Ahrefs (ещё один Очень Серьёзный Индустриальный Пользователь OCaml, член Консорциума Caml в придачу) сообщили о загадочных случайных сбоях с OCaml 4.03.0: это PR#7452 в баг-трекере Caml.

В их примере повторяемого сбоя сам компилятор ocamlopt.opt иногда падал или выдавал бессмысленный результат, когда компилировал большой исходный файл. Это не слишком удивительно, потому что ocamlopt.opt сам по себе является программой OCaml, скомпилированной компилятором ocamlopt.byte, но так было проще обсуждать и воспроизвести проблему.

Публично открытые комментарии к багу PR#7452 довольно хорошо показывают, что произошло дальше, а сотрудники Ahrefs подробно описали свою охоту за багом в этой статье. Так что я выделю только ключевые моменты этой истории.

  • Через 12 часов после открытия тикета, когда в обсуждении было уже 19 комментариев, Ангерран Декорн сообщил, что «все машины, которые смогли воспроизвести баг, работают на процессорах семейства Intel Skylake».
  • На следующий день я упомянул о случайных сбоях у ОСИПа и предложил отключить многопоточность (Hyper-Threading).
  • Ещё через день Джорис Джованнанджели подтвердил, что баг не воспроизводится при отключенном Hyper-Threading.
  • Параллельно Джорис обнаружил, что сбой происходит только если система поддержки выполнения OCaml собрана с параметром gcc -O2, но не gcc -O1. Оглядываясь назад, это объясняет отсутствие сбоев с отладочной версией окружения OCaml и с OCaml 4.02, поскольку они обе по умолчанию собираются с параметром gcc -O1.
  • Я выхожу на сцену и публикую следующий комментарий:
    Будет ли безумием предположить, что настройка gcc -O2 на окружении OCaml 4.03 выдаёт специфическую последовательность инструкций, которая вызывает аппаратный сбой (какие-то степпинги) в процессорах Skylake с Hyper-Threading? Возможно, это и безумие. С другой стороны, уже есть одна задокументированная аппаратная проблема с Hyper-Threading и Skylake (ссылка)
  • Марк Шинвелл связался с коллегами в Intel и сумел протолкнуть отчёт через отдел поддержки пользователей.

Затем ничего не происходило 5 месяцев, пока…

Открытие


26 мая 2017 года пользователь "ygrek" опубликовал ссылку на следующий журнал изменений из пакета с «микрокодом» от Debian:

* New upstream microcode datafile 20170511 [...]
* Likely fix nightmare-level Skylake erratum SKL150. Fortunately,
either this erratum is very-low-hitting, or gcc/clang/icc/msvc
won't usually issue the affected opcode pattern and it ends up
being rare.
SKL150 - Short loops using both the AH/BH/CH/DH registers and
the corresponding wide register *may* result in unpredictable
system behavior. Requires both logical processors of the same
core (i.e. sibling hyperthreads) to be active to trigger, as
well as a "complex set of micro-architectural conditions"


Эррата SKL150 была задокументирована компанией Intel в апреле 2017 года и описана на странице 65 в Обновлении спецификаций семейства процессоров Intel 6-го поколения. Похожая эррата упоминается под номерами SKW144, SKX150, SKZ7 для разновидностей архитектуры Skylake и KBL095, KBW095 для более новой архитектуры Kaby Lake. Слова «полный кошмар» не упоминаются в документации Intel, но приблизительно описывают ситуацию.

Несмотря на довольно расплывчатое описание («сложный набор микроархитектурных условий», и не говорите!) эта эррата бьёт прямо в цель: включенный Hyper-Threading? Есть такое! Проявляется псевдослучайно? Есть! Не имеет отношения ни к плавающей запятой, ни к векторным инструкциям? Есть! К тому же, готово обновление микрокода, которое устраняет эту ошибку, оно мило упаковано в Debian и готово к загрузке в наши тестовые машины. Через несколько часов Джорис Джованнанджели подтвердил, что сбой исчез после обновления микрокода. Я запустил ещё больше тестов на своей новёхонькой рабочей станции с процессором Skylake (спасибо отделу снабжения Inria) и пришёл к тому же выводу, поскольку тест, который обваливался быстрее чем за 10 минут на старом микрокоде, проработал 2,5 суток без проблем на новом микрокоде.

Есть ещё одна причина считать, что SKL150 — виновник наших проблем. Дело в том, что проблемный код, описанный в этой эррате, как раз и генерирует GCC при компиляции системы поддержки выполнения OCaml. Например, в файле byterun/major_gc.c для функции sweep_slice получается такой код C:

hd = Hd_hp (hp);
/*...*/
Hd_hp (hp) = Whitehd_hd (hd);

После макрорасширения это выглядит так:

hd = *hp;
/*...*/
*hp = hd & ~0x300;

Clang компилирует этот код банальным способом, используя только регистры полной ширины:

movq    (%rbx), %rax
[...]
andq    $-769, %rax             # imm = 0xFFFFFFFFFFFFFCFF
movq    %rax, (%rbx)

Однако GCC предпочитает использовать 8-битный регистр %ah для работы с битами от 8 до 15 из полного регистра %rax, оставляя остальные биты без изменений:

movq    (%rdi), %rax
[...]
andb    $252, %ah
movq    %rax, (%rdi)

Эти два кода функционально эквиваленты. Одной возможной причиной выбора GCC может быть то, что его код более компактный: 8-битная константа $252 помещается в один байт кода, в то время как 32-битной, расширенной до 64 бит, константе $-769 нужно 4 байта. Во всяком случае, сгенерированный GCC код использует и %rax, и %ah и, в зависимости от уровня оптимизации и неудачного стечения обстоятельств, такой код может окончиться циклом, достаточно маленьким, чтобы вызвать баг SKL150.

Так что, в итоге, это всё-таки аппаратный баг. Говорил же!

Эпилог


Intel выпустила обновления микрокода для процессоров Skylake и Kaby Lake, которые исправляют или обходят проблему. Debian опубликовала подробные инструкции для проверки, подвержен ли багу ваш процессор и как получить и применить обновления микрокода.

Публикация о баге и выпуск микрокода оказались очень своевременными, потому что у нескольких проектов на OCaml начали происходить таинственные сбои. Например, у Lwt, Coq и Coccinelle.

Об аппаратном баге написал ряд технических сайтов, например, Ars Technica, HotHardware, Tom's Hardware и Hacker's NewsGeekTimes — прим. пер.].
Проголосовать:
+140
Сохранить: