Повесть о невозможном баге
Все началось с сообщения об ошибке с телефона с процессором, используемым чипсетом Exynos на телефонах Samsung в Европе. Приложения, созданные с помощью нашего ПО, падали с
SIGILL
в совершенно случайных местах. Ничто не могло разумно объяснить, что происходило, а само падение происходило с валидными процессорными инструкциями. Это сразу же заставило нас подозревать неудачную очистку кэша инструкций.После рассмотрения всего JIT кода на предмет сброса кэша мы были уверены, что вызывали
__clear_cache
правильно. Это сподвигло нас посмотреть на то, как другие виртуальные машины или компиляторы производят сброс кэша на ARM64, и мы нашли список опечаток/исправлений для спецификации Cortex A53. Описания перечисленных проблем от ARM являются неопределенными и трудно воспринимаемыми, поэтому мы попробовали все же найти обходной путь. Но и здесь ничего не получилось.Затем мы зашли с другой стороны. А может проблема в обработчике сигналов? Нет. Неуклюжая эмуляция процессора в пользовательском пространстве? Нет. Сломанная реализация
libc
? Хорошая попытка. Неисправное оборудование? Мы воспроизвели это на нескольких устройствах. Плохая удача или карма? Да!Некоторые из нас не могли уснуть с такой удивительной головоломкой перед собой и продолжали смотреть на дампы приложений. Но была одна забавная вещь: неисправный адрес всегда был на третьей или четвертой строке дампов памяти.
Это была наша единственная зацепка, а когда дело касается такой трудной для понимания ошибки, то ни о каких-либо случайностях и речи быть не может. Наши дампы памяти были выровнены по 16 байт, в то время как
SIGILL
всегда происходило в диапазоне между 0x40-0x7f
или 0xc0-0xff
. Поэтому мы отформатировали снимки памяти таким образов, чтобы легче было проверить работу аллокатора:$ grep SIGILL *.log
custom_01.log:E/mono (13964): SIGILL at ip=0x0000007f4f15e8d0
custom_02.log:E/mono (13088): SIGILL at ip=0x0000007f8ff76cc0
custom_03.log:E/mono (12824): SIGILL at ip=0x0000007f68e93c70
custom_04.log:E/mono (12876): SIGILL at ip=0x0000007f4b3d55f0
custom_05.log:E/mono (13008): SIGILL at ip=0x0000007f8df1e8d0
custom_06.log:E/mono (14093): SIGILL at ip=0x0000007f6c21edf0
[...]
С помощью этого сформулировали первую хорошую гипотезу: неудачный сброс кэша происходил всегда на старших 64 байтах каждого 128-байтового блока. Эти цифры, если вы имеет дело с низкоуровневым программированием, сразу же напомнят о размерах кэш-линий. С этого момента все начало приобретать смысл.
Ниже приведен псевдо-код того, как
libgcc
делает сброс кэша на arm64:void __clear_cache (char *address, size_t size)
{
static int cache_line_size = 0;
if (!cache_line_size)
cache_line_size = get_current_cpu_cache_line_size ();
for (int i = 0; i < size; i += cache_line_size)
flush_cache_line (address + i);
}
В примере выше
get_current_cpu_cache_line_size
представляет собой процессорную инструкцию, которая возвращает размер кэш-линий, а flush_cache_line
очищает кэш-линию по заданному адресу.В то время мы использовали собственную реализацию данной функции, поэтому решили отдельно запустить ее и вывести размеры кэш-линий процессором. И вдруг оно напечатало 128 и 64. Мы дважды проверили, что это было на самом деле. После этого мы взяли справочник данного процессора, и оказалось, что у старших ядер (big) размер кэш-линий составляет 128 байт, а младших (LITTLE) — 64.
Выходило так, что сначала
__clear_cache
мог быть вызван на big-ядре с 128 байтными кэш-линиями инструкций, а потом на одном из LITTLE-ядер, пропуская все остальные при сбросе. Проще некуда. Мы удалили кэширование и все заработало.Выводы
Некоторые процессоры ARM big.LITTLE могут иметь ядра с различными размерами кэш-линий, и в значительной степени ни один код не готов иметь дело с этим, т.к. предполагается, что все ядра являются симметричными.
Хуже того, даже набор инструкций ARM не готов к этому. Проницательный читатель может догадаться, что вычисление строки кэша при каждом вызове недостаточно для пользовательского кода: может так произойти, что процесс запускается на одном ядре, а выполняет
__clear_cache
с определенным размером кэш-линии на другом, что может оказаться неправдой. Таким образом, мы должны попытаться выяснить глобальный минимальный размер кэш-линий среди всех ядер. Здесь находится наше исправление для Mono: Pull Request. Другие проекты уже позаимствовавшие наше исправление: Dolphin и PPSSPP.Комментарии (12)
lorc
24.01.2017 20:22+2Я понимаю, что глупо общаться с переводом, но всё же хочу прояснить несколько деталей.
У ARMv8 нет инструкции, которая получает размер кеша. Вводить отдельную инструкцию для такой операции было бы глупо. Вместо этого есть регистры которые описывают структуру кеша. Они описаны в ARM Architecture Reference Manual ARMv8 в разделе D3.4.2 Cache identification. И первым делом там сказано вот что:
The ARMv8 cache identification registers describe the implemented caches that are affected by cache maintenance
instructions executed on the PE
PE — это Processing Element, т.е. просто процессор. Короче, регистры описывают структуру кеша на конкретном процессоре. Что, в принципе абсолютно логично.
То что шедуллер ОС может перекидывать код с одного ядра на другое, это не проблема архитектуры процессора. Пользовательский код должен учитывать это и например просить шедуллер не делать так.
Поэтому, я сильно не согласен с вот этой фразой:
Хуже того, даже набор инструкций ARM не готов к этому.
Это просто ошибка в имплементации libgcc.mkarev
24.01.2017 20:57+2Пользовательский код должен учитывать это и например просить шедуллер не делать так.
Такое возможно сделать в юзерспейсе?
lorc
24.01.2017 21:03+3Ага. Системный вызов sched_setaffinity() позволит прибить поток к конкретному ядру.
beeruser
25.01.2017 00:20+3>> set_affinity()
ОС может работать в режиме Cluster Migration или CPU Migration, когда пара ядер (большое и маленькое) видны как одно.
Старые Exynos типа 5410 могут работать лишь в первом режиме.
Аффинити тут не поможет.
Собственно это и была фишка big.LITTLE. Например Apple A10 работает так.
Более того — переключение может происходить без участия ОС, аппаратно.
Правильней таки получать минимальный размер строчки кеша откуда нибудь из ОС.
QtRoS
24.01.2017 22:48Мне кажется эта тема уже поднималась на Хабре.
То есть как бы сам факт такой необычной реализации уже не является новым, интереснее другое — как все-таки с этим жить? Не будет ли многопоточный код ломаться непредвиденно? И так на ARM более мягкие правила reordering'а, чем на x86, и плюс еще дополнительная нестабильность.beeruser
25.01.2017 00:26+2Никто лёгкой жизни и не обещал
"… While those CPU cores are functionally equivalent, they may differ in implementation details such as cache topology. "
m08pvv
25.01.2017 11:07Судя по изменениям, баг просто сделали более сложным в воспроизведении, но не избавились полностью. И даже сами признают, что может произойти, просто гораздо менее вероятно.
mkarev
Вот так поворот! Можно уточнить — речь идет о размере кеш линий инструкций или инструкций и данных?
PS: Спасибо за статью
szKarlen
речь идет про кэш инструкций.