Когда кто-то произносит слово многоядерный, то мы бессознательно подразумеваем 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.
Поделиться с друзьями
-->

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


  1. mkarev
    24.01.2017 20:07
    +2

    у старших ядер (big) размер кэш-линий составляет 128 байт, а младших (LITTLE) — 64

    Вот так поворот! Можно уточнить — речь идет о размере кеш линий инструкций или инструкций и данных?


    PS: Спасибо за статью


    1. szKarlen
      24.01.2017 20:37
      +5

      речь идет про кэш инструкций.


  1. 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.


    1. mkarev
      24.01.2017 20:57
      +2

      Пользовательский код должен учитывать это и например просить шедуллер не делать так.

      Такое возможно сделать в юзерспейсе?


      1. lorc
        24.01.2017 21:03
        +3

        Ага. Системный вызов sched_setaffinity() позволит прибить поток к конкретному ядру.


        1. beeruser
          25.01.2017 00:20
          +3

          >> set_affinity()

          ОС может работать в режиме Cluster Migration или CPU Migration, когда пара ядер (большое и маленькое) видны как одно.
          Старые Exynos типа 5410 могут работать лишь в первом режиме.
          Аффинити тут не поможет.

          Собственно это и была фишка big.LITTLE. Например Apple A10 работает так.
          Более того — переключение может происходить без участия ОС, аппаратно.

          Правильней таки получать минимальный размер строчки кеша откуда нибудь из ОС.


      1. mkarev
        24.01.2017 21:04

        что-то типа?


        foreach core
          set_affinity
          get_cache_line_size


        1. lorc
          24.01.2017 21:09

          Ну если мы хотим узнать параметры кешей на каждом ядре — то да, именно так.


          1. mkarev
            25.01.2017 06:15

            Да, чтобы выяснить минимальный размер кэш линии, как было сказано в статье.
            А еще эти ядра могуть уходить в offline.
            Не знаю будет ли в этом случае работать set_affinity.


  1. QtRoS
    24.01.2017 22:48

    Мне кажется эта тема уже поднималась на Хабре.
    То есть как бы сам факт такой необычной реализации уже не является новым, интереснее другое — как все-таки с этим жить? Не будет ли многопоточный код ломаться непредвиденно? И так на ARM более мягкие правила reordering'а, чем на x86, и плюс еще дополнительная нестабильность.


    1. beeruser
      25.01.2017 00:26
      +2

      Никто лёгкой жизни и не обещал
      "… While those CPU cores are functionally equivalent, they may differ in implementation details such as cache topology. "


  1. m08pvv
    25.01.2017 11:07

    Судя по изменениям, баг просто сделали более сложным в воспроизведении, но не избавились полностью. И даже сами признают, что может произойти, просто гораздо менее вероятно.