[Перевод] Повесть о невозможном баге: big.LITTLE и кэширование

Когда кто-то произносит слово многоядерный, то мы бессознательно подразумеваем SMP. Это успешно срабатывало для нас до недавнего времени, пока ARM не объявила о big.LITTLE. Архитектура ARM big.LITTLE является первым массово производимым примером архитектуры AMP, и как мы увидим далее, она поднимает планку сложности многоядерного программирования еще выше.

Повесть о невозможном баге


Все началось с сообщения об ошибке с телефона с процессором, используемым чипсетом Exynos на телефонах Samsung в Европе. Приложения, созданные с помощью нашего ПО, падали с SIGILL в совершенно случайных местах. Ничто не могло разумно объяснить, что происходило, а само падение происходило с валидными процессорными инструкциями. Это сразу же заставило нас подозревать неудачную очистку кэша инструкций.

После рассмотрения всего JIT кода на предмет сброса кэша мы были уверены, что вызывали __clear_cache правильно. Это сподвигло нас посмотреть на то, как другие виртуальные машины или компиляторы производят сброс кэша на ARM64, и мы нашли список опечаток/исправлений для спецификации Cortex A53. Описания перечисленных проблем от ARM являются неопределенными и трудно воспринимаемыми, поэтому мы попробовали все же найти обходной путь. Но и здесь ничего не получилось.

Затем мы зашли с другой стороны. А может проблема в обработчике сигналов? Нет. Неуклюжая эмуляция процессора в пользовательском пространстве? Нет. Сломанная реализация libc? Хорошая попытка. Неисправное оборудование? Мы воспроизвели это на нескольких устройствах. Плохая удача или карма? Да!

Некоторые из нас не могли уснуть с такой удивительной головоломкой перед собой и продолжали смотреть на дампы приложений. Но была одна забавная вещь: неисправный адрес всегда был на третьей или четвертой строке дампов памяти.

b743ef2cc1844a528f1834f510c6f98a.png

Это была наша единственная зацепка, а когда дело касается такой трудной для понимания ошибки, то ни о каких-либо случайностях и речи быть не может. Наши дампы памяти были выровнены по 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.

Комментарии (0)

© Habrahabr.ru