Повість про неможливе ба: big.LITTLE і кешування

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

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.