О кэшах в микроконтроллерах ARM
Привет!
В предыдущей статье мы для ускорения графики на микроконтроллере применяли процессорный кэш. При этом мы использовали режим «write-through». Тогда мы писали о некоторых преимуществах и недостатках связанных с «write-through» режимом, но это был лишь беглый обзор. В этой статье я, как и обещал, хочу подробней рассмотреть типы кэшей в ARM микроконтроллерах, а также сравнить их. Конечно, все это будет рассмотрено с точки зрения программиста, и вдаваться в детали работы контроллера памяти в данной статье мы не планируем.
Начну с того на чем остановился в предыдущей статье, а именно, на разнице между «write-back» и «write-through» режимами, поскольку именно эти два режима чаще всего используются. Если кратко, то:
- «Write-back». Данные по записи подают только в кэш. Реальная запись в память откладывается до тех пор пока кэш не переполнится и не потребуется место для новых данных.
- «Write-through». Запись происходит «одновременно» и в кэш и в память.
Write-through
Преимуществами режима write-through считается простота использования, что потенциально уменьшает количество ошибок. Действительно, в этом режиме память всегда находится в корректном состоянии и не требует дополнительных процедур обновления.
Конечно кажется будто это должно сильно сказаться на производительности, но сам STM в этом документе говорит, что это не так:
Write-through: triggers a write to the memory as soon as the contents on the cache line are written to. This is safer for the data coherency, but it requires more bus accesses. In practice, the write to the memory is done in the background and has a little effect unless the same cache set is being accessed repeatedly and very quickly. It is always a tradeoff.
То есть, изначально мы предполагали, что раз запись происходит в память, то на операциях записи производительность будет примерно такой же как и совсем без кэша, а основной выигрыш происходит за счет повторных чтений. Однако, STM это опровергает, говорится что данные в в память попадают «в фоне», поэтому производительность на записи практически такая же как и в режиме «write-back». Это, в частности, может зависеть от внутренних буферов контроллера памяти (FMC).
Минусы режима «write-through»:
- При последовательном и быстром доступе в одну и ту же память производительность может снижаться. В режиме «write-back» последовательные частые доступы к одной памяти будут, наоборот, являться плюсом.
- Как и в случае с «write-back» все равно нужно делать cache invalidate после окончания DMA операций.
- Баг «Data corruption in a sequence of Write-Through stores and loads» в некоторых версиях Cortex-M7. Нам указал на него один из разработчиков LVGL.
Write-back
Как уже говорилось выше, в этом режиме (в отличие от «write-through») данные в общем случае не попадают в память по записи, а попадают только в кэш. Как и у «write-through», у этой стратегии имеются два «под варианта» — 1) «write allocate», 2) «no write allocate». Об этих вариантах мы поговорим дальше.
Write Allocate
Как правило, в кэшах всегда используется «read allocate» — то есть по промаху (cache miss) на чтение данные забираются из памяти и размещаются в кэше. Аналогично, при промахе на запись данные могут подгружаться в кэш («write allocate») или не подгружаться («no write allocate»).
Обычно на практике используются сочетания «write-back write allocate» или «write-through no write allocate». Далее в тестах мы попробуем чуть более детально проверить в каких ситуациях использовать «write allocate», а в каких «no write allocate».
MPU
Прежде чем переходить к практической части нам необходимо разобраться как же задавать параметры региона памяти. Для выбора режима кэша (или его отключения) для определенного региона памяти в архитектуре ARMv7-M используется MPU (Memory Protection Unit).
В контроллере MPU поддерживается задание регионов памяти. Конкретно в архитектуре ARMV7-M может быть до 16 регионов. Для этих регионов можно независимо устанавливать: стартовый адрес, размер, права доступа (read/write/execute и т.д.), атрибуты — TEX, cacheable, bufferable, shareable, а так же и другие параметры. С помощью такого механизма, в частности, можно добиться любого типа кэширования для определенного региона. Например, мы можем избавиться от необходимости вызывать cache_clean/cache_invalidate просто выделив регион памяти под все операции DMA и пометив эту память как не кэшируемую.
Нужно отметить важный момент при работе с MPU:
The base address, size and attributes of a region are all configurable, with the general rule that all regions are naturally aligned. This can be stated as:
RegionBaseAddress[(N-1):0] = 0, where N is log2(SizeofRegion_in_bytes)
Иными словами, стартовый адрес региона памяти должен быть выровнен на его собственный размер. Если у вас, к примеру, регион 16 Кб, то выравнивать нужно на 16 Кб. Если регион памяти 64 Кб, то выравниваем на 64 Кб. И так далее. Если этого не сделать, то MPU может автоматически «обрезать» регион под размер соответствующий его стартовому адресу (проверено на практике).
Кстати, в STM32Cube есть несколько ошибок. Например:
MPU_InitStruct.BaseAddress = 0x20010000;
MPU_InitStruct.Size = MPU_REGION_SIZE_256KB;
Видно, что стартовый адрес выровнен на 64 Кб. А размер региона хотим 256 Кб. В этом случае придется создавать 3 региона: первый 64 Кб, второй — 128 Кб, и третий 64 Кб.
Задавать нужно только регионы с отличными от стандартных свойствами. Дело в том, что атрибуты всех памятей при включении кэша процессора описаны в архитектуре ARM. Есть стандартный набор свойств (к примеру, поэтому память SRAM STM32F7 имеет режим «write-back write-allocate» по умолчанию), Поэтому если вам понадобится не стандартный режим для какой-то из памятей, то нужно будет задать его свойства через MPU. При этом внутри региона можно задать подрегион со своими свойствами, Выделив внутри этого региона еще один с большим приоритетом с требуемыми свойствами.
TCM
Как следует из документации (раздел 2.3 Embedded SRAM), первые 64 Кб SRAM в STM32F7 некэшируемые. В самой архитектуре ARMv7-M по адресу 0×20000000 находится память SRAM. TCM тоже относится к SRAM, но находится на другой шине относительно остальных памятей (SRAM1 и SRAM2), и располагается «ближе» к процессору. Из-за этого данная память очень быстрая, по сути дела, имеет такую же скорость как и кэш. И из за этого кэширование не нужно, и этот регион не возможно сделать кэшируемым. По сути TCM это еще один такой вот кэш.
Instruction cache
Стоит отметить, что все рассмотренное выше относится к кэшу данных (D-Cache). Но кроме кэша данных в ARMv7-M предусмотрен и кэш инструкций — Instruction cache (I-Cache). I-Cache позволяет перенести часть исполняемых (и следующих) инструкций в кэш, что может значительно ускорить работу программы. Особенно, в тех случаях, когда код находится в более медленной памяти чем FLASH, к примеру, QSPI.
Чтобы уменьшить непредсказуемость в тестах с кэшем ниже, мы намеренно отключим I-Cache и будем думать исключительно о данных.
При этом хочу отметить, что включается I-Cache достаточно просто и не требует никаких дополнительных действий со стороны MPU в отличие от D-Cache.
Синтетические тесты
После обсуждения теоретической части, давайте перейдем к тестам, чтобы лучше понять разницу и сферы применимости той или иной модели. Как я и говорил выше, отключаем I-Cache и работаем только с D-Cache. Так же я намеренно компилирую с -O0, чтобы циклы в тестах не оптимизировались. Тестировать будем через внешнюю память SDRAM. С помощью MPU я разметил регион 64 Кб, и будем выставлять этому региону нужные нам атрибуты.
Так как тесты с кэшами очень капризные и находятся под влиянием всего и вся в системе — сделаем код линейным и непрерывным. Для этого отключаем прерывания. Так же, замерять время будем не таймерами, а DWT (Data Watchpoint and Trace unit), в котором есть 32 битный счетчик процессорных тактов. На его основе (на просторах интернета) люди делают микросекундные задержки в драйверах. Счетчик довольно быстро переполняется на системной частоте 216 МГц, но до 20 секунд померить можно. Просто будем об этом помнить, и сделаем тесты в этом временном интервале, предварительно обнуляя счетчик тактов перед стартом.
Посмотреть полные коды тестов можно тут. Все тесты были проведены на плате 32F769IDISCOVERY.
Non-cacheable memory VS. write-back
Итак, начнем с совсем простых тестов.
Просто последовательно пишем в память.
dst = (uint8_t *) DATA_ADDR;
for (i = 0; i < ITERS * 8; i++) {
for (j = 0; j < DATA_LEN; j++) {
*dst = VALUE;
dst++;
}
dst -= DATA_LEN;
}
Так же последовательно пишем в память, но не по одному байту за раз, а немного развернем циклы.
for (i = 0; i < ITERS * BLOCKS * 8; i++) {
for (j = 0; j < BLOCK_LEN; j++) {
*dst = VALUE;
*dst = VALUE;
*dst = VALUE;
*dst = VALUE;
dst++;
}
dst -= BLOCK_LEN;
}
Так же последовательно пишем в память, но теперь еще и чтение добавим.
for (i = 0; i < ITERS * BLOCKS * 8; i++) {
dst = (uint8_t *) DATA_ADDR;
for (j = 0; j < BLOCK_LEN; j++) {
val = VALUE;
*dst = val;
val = *dst;
dst++;
}
}
Если запустить все эти три теста, то они дадут абсолютно одинаковый результат какой бы режим вы не выбрали:
mode: nc, iters=100, data len=65536, addr=0x60100000
Test1 (Sequential write):
0s 728ms
Test2 (Sequential write with 4 writes per one iteration):
7s 43ms
Test3 (Sequential read/write):
1s 216ms
И это резонно, SDRAM не такая уж и медленная, особенно если учесть внутренние буферы FMC, через который она подключена. Тем не менее, я ожидал небольшой вариации в цифрах, но оказалось что ее на этих тестах нет. Ну что же, будем думать дальше.
Давайте попробуем «подпортить» жизнь SDRAM смешивая чтения и записи. Для этого развернем циклы добавим такую распространенную на практике вещь как инкремент элемента массива:
for (i = 0; i < ITERS * BLOCKS; i++) {
for (j = 0; j < BLOCK_LEN; j++) {
// 16 lines
arr[i]++;
arr[i]++;
***
arr[i]++;
}
}
Результат:
Не кэшируемая память: 4s 743ms
Write-back: : 4s 187ms
Уже лучше — с кэшем оказалось на пол секунды быстрей. Давайте попробуем еще усложнить тест — добавим доступ по «разреженным» индексам. К примеру, с одним индексом:
for (i = 0; i < ITERS * BLOCKS; i++) {
for (j = 0; j < BLOCK_LEN; j++) {
arr[i + 0 ]++;
***
arr[i + 3 ]++;
arr[i + 4 ]++;
arr[i + 100]++;
arr[i + 6 ]++;
arr[i + 7 ]++;
***
arr[i + 15]++;
}
}
Результат:
Не кэшируемая память: 11s 371ms
Write-back: : 4s 551ms
Теперь разница с кэшем стала более чем заметна! И в довершение введем второй такой индекс:
for (i = 0; i < ITERS * BLOCKS; i++) {
for (j = 0; j < BLOCK_LEN; j++) {
arr[i + 0 ]++;
***
arr[i + 4 ]++;
arr[i + 100]++;
arr[i + 6 ]++;
***
arr[i + 9 ]++;
arr[i + 200]++;
arr[i + 11]++;
arr[i + 12]++;
***
arr[i + 15]++;
}
}
Результат:
Не кэшируемая память: 12s 62ms
Write-back: : 4s 551ms
Видим как время для не кэшируемой памяти подросло еще почти на секунду, в то время как для кэша осталось прежним.
Write allocate VS. no write allocate
Теперь давайте разберемся с режимом «write allocate». Тут еще сложней увидеть разницу, т.к. если в ситуации между не кэшируемой памятью и «write-back» становятся хорошо видны уже начиная с 4-го теста, то различия между «write allocate» и «no write allocate» до сих пор тестами не вскрылись. Давайте подумаем — когда «write allocate» будет быстрей? Например, когда у вас есть много записей в последовательные ячейки памяти, а чтений из этих ячеек памяти мало. В этом случае в режиме «no write allocate» будем получать постоянные промахи, и подгружаться по чтению в кэш будут совсем не те элементы. Давайте смоделируем такую ситуацию:
for (i = 0; i < ITERS * BLOCKS; i++) {
for (j = 0; j < BLOCK_LEN; j++) {
arr[j + 0 ] = VALUE;
***
arr[j + 7 ] = VALUE;
arr[j + 8 ] = arr[i % 1024 + (j % 256) * 128];
arr[j + 9 ] = VALUE;
***
arr[j + 15 ] = VALUE;
}
}
Здесь в 15 из 16 записей выставляется константа VALUE, в то время как чтение осуществляется из разных (и не связанных с записью) элементов arr[i % 1024 + (j % 256) * 128]. Получается, что при стратегии no write allocate только эти элементы и будут загружаться в кэш. Причина по которой используется такая индексация (i % 1024 + (j % 256) * 128) — «ухудшение скорости» FMC/SDRAM. Так как обращения к памяти по существенно различным (не последовательным) адресам, могут существенно сказываться на скорости работы.
Результат:
Write-back : 4s 720ms
Write-back no write allocate: : 4s 888ms
Наконец-то получили разницу, пусть и не настолько заметную, но уже видимую. То есть наша гипотеза подтвердилась.
И наконец, самый сложный, на мой взгляд, случай. Хотим понять когда «no write allocate» лучше чем «write allocate». Первый лучше если мы «часто» обращаемся к адресам, с которыми в ближайшее время работать не будем. Такие данные, не нужно заносить в кэш.
В следующем тесте в случае «write allocate» данные будут заполняться по чтению и по записи. Я сделал массив «arr2» на 64 Кб, поэтому кэш будет сбрасываться, чтобы подкачать новые данные. В случае же с «no write allocate» я сделал массив «arr» на 4096 байт, и только он попадет в кэш, а значит данные кэша сбрасываться в память не будут. За счет этого и попробуем получить хотя бы небольшой выигрыш.
arr = (uint8_t *) DATA_ADDR;
arr2 = arr;
for (i = 0; i < ITERS * BLOCKS; i++) {
for (j = 0; j < BLOCK_LEN; j++) {
arr2[i * BLOCK_LEN ] = arr[j + 0 ];
arr2[i * BLOCK_LEN + j*32 + 1 ] = arr[j + 1 ];
arr2[i * BLOCK_LEN + j*64 + 2 ] = arr[j + 2 ];
arr2[i * BLOCK_LEN + j*128 + 3] = arr[j + 3 ];
arr2[i * BLOCK_LEN + j*32 + 4 ] = arr[j + 4 ];
***
arr2[i * BLOCK_LEN + j*32 + 15] = arr[j + 15 ];
}
}
Результат:
Write-back : 7s 601ms
Write-back no write allocate: : 7s 599ms
Видно, что «write-back» «write allocate» режим чуть-чуть быстрей. Но главное, что быстрей.
Лучшей демонстрации у меня добиться не получилось, но я уверен, что есть практические ситуации, когда разница более ощутима. Читатели могут предложить свои варианты!
Практические примеры
Давайте перейдем от синтетических примеров к реальным.
ping
Один из самых простых — это ping. Его легко запустить, а время можно смотреть прямо на хосте. Embox был собран с оптимизацией -O2. Сразу приведу результаты:
Не кэшируемая память : ~0.246 c
Write-back : ~0.140 c
OpenCV
Еще одним примером реальной задачи на которой мы хотели попробовать работу подсистемы cache это OpenCV на STM32F7. В той статье было показано, что запустить вполне реально, но производительность была довольно низкая. Мы используем для демонстрации стандартный пример, который выделяет границы на основе фильтра Canny. Давайте измерим время работы с кешами (и D-cache и I-cache) и без.
gettimeofday(&tv_start, NULL);
cedge.create(image.size(), image.type());
cvtColor(image, gray, COLOR_BGR2GRAY);
blur(gray, edge, Size(3,3));
Canny(edge, edge, edgeThresh, edgeThresh*3, 3);
cedge = Scalar::all(0);
image.copyTo(cedge, edge);
gettimeofday(&tv_cur, NULL);
timersub(&tv_cur, &tv_start, &tv_cur);
Без кэша:
> edges fruits.png 20
Processing time 0s 926ms
Framebuffer: 800x480 32bpp
Image: 512x269; Threshold=20
С кэшем:
> edges fruits.png 20
Processing time 0s 134ms
Framebuffer: 800x480 32bpp
Image: 512x269; Threshold=20
То есть, 926ms и 134ms ускорение почти в 7 раз.
На самом деле у нас достаточно часто спрашивают про OpenCV на STM32, в частности какая производительность. Получается FPS конечно не высокий, но 5 кадров в секунду, вполне реально получить.
Не кэшируемая или кэшируемая память, но с cache invalidate?
В реальных устройствах повсеместно используется DMA, естественно с ним связаны трудности, ведь нужно синхронизировать память даже для режима «write-through». Возникает естественное желание просто выделить кусок памяти который будет не кэшируемый и использовать его при работе с DMA. Немного отвлекусь. В Linux это делается функцию через dma_coherent_alloc (). И да, это очень эффективный метод, например, когда идет работа с сетевыми пакетами в ОС, пользовательские данные проходят большой этап обработки прежде чем дойти до драйвера, а в драйвере подготовленные данные со всем шапками копируются в буферы, которые используют не кэшируемую память.
А есть случаи когда в драйвере с DMA более предпочтителен clean/invalidate? Да, есть. К примеру, видеопамять, которая нас и побудила более подробно разобраться с работой cache (). В режиме двойной буферизации у системы есть два буфера, в которые она поочередно рисует, а потом отдает видеоконтроллеру. Если делать такую память не кэшируемой, то случится падение в производительности. Поэтому лучше сделать clean перед тем как отдать буфер в видеоконтроллер.
Заключение
Мы немного разобрались с разными вида кэшей в ARMv7m: write-back, write-through, а также настроек «write allocate» и «no write allocate». Построили синтетические тесты, в которых попытались выяснить когда один режим лучше другого, а также рассмотрели практические примеры с ping и OpenCV. В Embox мы еще только занимаемся данной тематикой, поэтому соответствующая подсистема пока прорабатывается. Хотя достоинства в использовании кэшей определенно заметны.
Все примеры можно посмотреть и воспроизвести собрав Embox из открытого репозитория.
P.S.
Если вам интересна тема системного программирования и OSDev, то уже завтра будет проходить конференция OS Day! В этом году она проходит в онлайне, так что желающие не пропустите! Embox выступает завтра в 12.00