Когда кто-то произносит слово многоядерный, то мы бессознательно подразумеваем SMP. Это успешно срабатывало для нас до недавнего времени, пока ARM не объявила о big.LITTLE. Архитектура ARM big.LITTLE является первым массово производимым примером архитектуры AMP, и как мы увидим далее, она поднимает планку сложности многоядерного программирования еще выше.
Повесть о невозможном баге
Все началось с сообщения об ошибке с телефона с процессором, используемым чипсетом 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.
Автор: szKarlen