Работа с кэшем и режимами кэширования под x86 архитектурой на примере одной разработки
Меня зовут Mike Gorchak, я работаю «обычным сеньором» в QNX Software Systems / BlackBerry в отделе engineering services, graphics team. Engineering services — это как аутсорс, только все разработки так или иначе свяазаны с нашей операционной системой QNX, плюс специализация на кастомных решениях для заказчиков и их оптимизация. Основными моими заказчиками являются Tier 1 из мира automotive.
Одной из основных моих специализаций последние 11 лет является x86 платформа, на базе решений от Intel (чипсеты, процессоры, GPU). 5 лет назад Intel зашла на рынок automotive со своей A-серией процессоров Intel Atom, архитектура ApolloLake со встроенной GPU
Одной из частей GPU на интелловской автомобильной платформе является медиа-процессор, который может сжимать и расжимать видеоряд используя многие современные кодеки с довольно приличной скоростью, как для такого embedded устройства. Драйвера были написаны, часть портирована как VA API драйвер Intel под Linux. Основной заказчик этого драйвера — внутренний, другая команда, которая занимается разработкой а-ля чёрного ящика для автомобилей, вся информация с экранов, камер, датчиков, сжимается и сохраняется. И если камера выдаёт 60 FPS, то и сжимать нужно со скоростью 60 FPS, чтобы не пропустить ни одного фрейма. Разработчики другой команды во время того как писались BSP и прочие драйвера для новой платформы использовали desktop аналог для разработки. Они говорят, мы всё протестировали и больше 40 FPS никак выдать не можем, а тестируем на десктопном аналоге — получается минимум 120 FPS, а автомобильный не должен от него отставать сильно.
Начинаем разбираться. В чём отличие десктопного варианта и автомобильного? У десктопного варианта есть LLC (Last Level Cache) кэш. Кэш называют LLC, когда он последний, например, для процессора он будет L3, а для GPU он будет L4. И он объединяет между собой не только процессор, но и другие устройства. Другие устройства постоянно занимаются процессом называемым Cache Snooping — они отслеживают записи и чтения в регионах памяти, в которых идёт обмен с устройствами и кладут данные в LLC кэш, т.к. могут его использовать вместе с процессором.
Подробнее про LLC: en.wikichip.org/...fers,cores, IGP, and DSP
Подробне про Bus (Cache) Snooping: en.wikipedia.org/wiki/Bus_snooping
Используя LLC кэш процессор позволяет буферам с данными, которыми обменивается медиа-процессор в GPU и СPU быть кэшируемыми с точки зрения центрального процессора, что несомненно сказывается на быстродействии самым положительным образом. А вот автомобильная платформа LLC кэша не имеет, cache snooping протокол не работает, поэтому все буфера обмена должны быть uncacheable (некэшируемые) с точки зрения процессора, чтобы любые изменения делаемые GPU и CPU были мгновенно видны друг-другу. Ок, запускаем тест на измерение memory bandwidth (UC — uncacheable, WB — write-back — самый обыкновенный режим кэширования региона памяти используемый на x86 по умолчанию):
size UC->WB, WB->UC memcpy ------ ------------- 256 41.44 Mb/s 512 41.46 Mb/s 1024 41.49 Mb/s 2048 41.46 Mb/s 4096 41.44 Mb/s 8192 37.31 Mb/s 16384 37.31 Mb/s
Приехали. Пропускная способность некэшируемой памяти на этой платформе около 110x раз медленее кэшируемой. Простые расчёты показывают, что если мы будем копировать Full HD видео фрейм, который занимает 1920*1080*1.5 (NV12) = 3,110,400 байт, то в секунду мы можем залить для GPU или забрать назад только 13 фреймов.
Немного полезной теории: Intel® 64 and IA-32 Architectures Software Developer Manuals: software.intel.com/...p/articles/intel-sdm.html
Раздел 11.3 METHODS OF CACHING AVAILABLE описывает режимы кэширования регионов памяти, нас интересуют только три из них:
Strong Uncacheable (UC) or Uncacheable (UC-) — Регионы в системной памяти, имеющие этот аттрибут являются некэшируемыми. Все чтения и записи сразу появляются на системной шине и выполняются в порядке очереди заданной кодом при обращении. Этот режим кэширования подходит для доступа к memory mapped I/O при работе с устройствами. Когда используется для обычной системной памяти, он значительно замедляет работу процессора, т.к. он ожидает чтения и записи в память каждого обращения.
Write Combining (WC) — Тоже самое, что и Uncacheable (UC-). Но все записи осуществляются через Write Buffer внутри процессора, обычно это от одного до нескольких десятков cachelines. Реализация отличается от процессора к процессору, но общий подход следующий — все последовательные записи или записи не выходящие за пределы размера буфера, попадают в этот самый write buffer, если происходит выход за границы write buffer или он переполнен, то write buffer или его фрагмент сохраняется в памяти. Любое чтение из WC региона памяти также осуществляет сброс буфера в память, работая как posting read механизм. Также ряд процессорных команд делают тоже самое, например MFENCE, но быстрее, т.к. не нужно фиктивное чтение из некэшируемой памяти. Чтение такое же медленное, как и в случае с Uncacheable (UC-) памятью, но ряд процессорных команд могут использовать Write Buffer временно как Read Buffer, т.к. при чтении он полностью сброшен и доступен для временных операций. Это операция называется speculative reads.
Write-back (WB) — Чтение и запись проходят через кэши всех уровней по усмотрению процессора. Самый быстрый доступ к памяти, он же является доступом по умолчанию.
Так как мы используем QNX, который предоставляет очень удобные механизмы тонкого тюнинга и контроля маппинга памяти с разными режимами кэширования, мы решаем уйти от UC (uncacheable) и использовать WC (write-combining режим записи для некэшируемой памяти).
Например, выделение памяти с режимом кэширования Write-back (WB):
mmap(NULL, pix_buffer_mem_size, PROT_READ | PROT_WRITE, MAP_ANON | MAP_SHARED, NOFD, 0);
Выделение памяти с режимом кэширования Uncacheable (UC-):
mmap(NULL, pix_buffer_mem_size, PROT_READ | PROT_WRITE | PROT_NOCACHE, MAP_ANON | MAP_SHARED, NOFD, 0);
Выделение памяти с режимом кэширования Write-Combining (WC):
shm_fd = shm_open(SHM_ANON, O_RDWR | O_CREAT, 0); shm_ctl(shm_fd, SHMCTL_ANON | SHMCTL_LAZYWRITE, 0, pix_buffer_mem_size); mmap64(NULL, pix_buffer_mem_size, PROT_READ | PROT_WRITE, MAP_SHARED, shm_fd, 0); close(shm_fd);
В Linux всё гораздо сложнее — для получения WC или UC памяти нужно писать свой драйвер для выделения такой памяти и задания нужны PAT аттрибутов. Можно взять за базу этот проект: github.com/...nsqueeze/uncached-ram-lkm
Тестируем скорость доступа к Write-Combining (WC) памяти:
size WC->WB WB->WC memcpy memcpy ------ ------------- ------------- 256 41.44 Mb/s 2441.41 Mb/s 512 41.46 Mb/s 3814.70 Mb/s 1024 41.49 Mb/s 4069.01 Mb/s 2048 41.46 Mb/s 4359.65 Mb/s 4096 41.44 Mb/s 4695.01 Mb/s 8192 37.31 Mb/s 4694.71 Mb/s 16384 37.31 Mb/s 4694.71 Mb/s
Уже лучше, гораздо лучше, но не для всех видов работы, т.к. забирать расжатые или сжатые фреймы с помощью центрального процессора мы всё равно можем очень медленно, как и в случае с некэшируемой памятью. Но писать почти с той же скоростью, что при работе с Write-back (WB) режимом кэширования.
Попробуем использовать SSE команды, которые поддерживают speculative reads из WC регионов памяти, например MOVNTDQA, в конце статьи я объясню почему именно эта команда с суффиксом NT в мнемонике.
Хороший образец кода для Streaming Load (Speculative Reads) можно посмотреть в исходниках Mesa3D. Код написан Intel и имеет BSD-подобную лицензию, за что им спасибо: gitlab.freedesktop.org/...n/streaming-load-memcpy.c
size WC->WB WB->WC WC->WB WB->WC memcpy memcpy movntdqa movntdqa ------ ------------- ------------- ------------- ------------- 256 41.44 Mb/s 2441.41 Mb/s 592.57 Mb/s 1695.42 Mb/s 512 41.46 Mb/s 3814.70 Mb/s 610.35 Mb/s 2774.33 Mb/s 1024 41.49 Mb/s 4069.01 Mb/s 629.23 Mb/s 3590.30 Mb/s 2048 41.46 Mb/s 4359.65 Mb/s 629.23 Mb/s 4069.01 Mb/s 4096 41.44 Mb/s 4695.01 Mb/s 629.23 Mb/s 4359.65 Mb/s 8192 37.31 Mb/s 4694.71 Mb/s 333.50 Mb/s 5085.94 Mb/s 16384 37.31 Mb/s 4694.71 Mb/s 333.50 Mb/s 5085.94 Mb/s
При доступе размером в одну страницу памяти — 4096 байт, получаем максимальный выигрышь на этой платформе, на других платформах скорости и гранулярность доступа могут отличаться, но одна страница выглядит логично и универсально также и для других архитектур интелловских процессоров. Итого 629 / 3 ~ 200 фреймов в секунду. Мы уложились. Всё работает. Но есть один ньюанс, загрузка ядра процессора, которое занимается копированием из Write-Combining (WC) региона памяти составляет около 30%, что довольно таки много, делая embedded процессор слегка тёплым, в районе
Так как современные автомобили имеют системы engine start/stop и выключают двигатель во время остановки, то всё питание всех систем идёт с аккумулятора, а не с генератора. То повышенное потребление нужно всё равно решать, рано или поздно. Пока тема горячая, нужно продолжать исследования дальше.
Вариантов по сути больше нет, только использовать кэшируемую память. Но проблема в том, что данные в памяти не когерентны с данными в кэше. И GPU об этом ничего не знает. Помните как в школе нас учили, что делить на ноль нельзя? И только повзрослев мы получили индульгенцию деления на ноль без последствий в виде CPU exceptions и прочих радостей. Используя FPU мы можем наслаждаться бесконечностью (infinity) в качестве результата. Все учебники и мануалы говорят, что использовать для устройств кэшируемую память нельзя, но если очень надо, то можно! А нам надо.
Вариант действий номер 1. Мы кладём и ложим данные для обработки медиа-процессором в кэшируемую память. Но надо позаботиться о том, чтобы данные из кэша записались в память. Как? Использовать старую добрую команду процессора WBINVD: www.felixcloutier.com/x86/wbinvd — Write back and flush Internal caches; initiate writing-back and flushing of external caches. Но у этой команды есть большие недостатки — она требует исполнения на уровне Ring 0 процессора — требует самый высокопривелигированный уровень из доступных для обычных приложения и ядер. Механизмы выполнить её в QNX существуют, но появляется вторая проблема, использование этой команды сродни разбиванию яиц кувалдой для омлета, мы будем 60 раз в секунду полностью уничтожать весь кэш процессора, причём всех четырёх ядер, т.к. они поддерживают когерентность кэша между собой. А вот за это нас точно по голове не погладят.
Вариант действий номер 2. Мы кладём и ложим данные для обработки медиа-процессором в кэшируемую память. Но надо позаботиться о том, чтобы данные из кэша записались в память. Как? Использовать «новые» команды процессора CLFLUSH и CLFLUSHOPT:
www.felixcloutier.com/x86/clflush
www.felixcloutier.com/x86/clflushopt
Данные команды работают на уровне cache line, поэтому нужно либо хардкодить размер кэшлайна 64 байта или спрашивать у CPUID инструкции. Второе предпочтительнее, если мы хотим использовать код не только на этой платформе. На помощь в реализации и имплементации workarounds для Intel Atom процессоров (а у нас именно он) приходит кладезь полезного кода — Mesa3D: gitlab.freedesktop.org/...ntel/common/gen_clflush.h . Код написан Intel и опять под BSD-like лицензией, что очень удобно, к тому же код не завязан на внутренности Mesa3D. Сама Intel захардкодила размер кэшлайна равным 64 байтам, наверное они что-то знают :) Либо скоро будут переписывать этот код. Но сегодня для наших нужд с использованием CPUID инструкции пока всё хорошо.
Необходимо осуществлять следующие правила — пишем данные в буфер для отправки, сбрасываем (flush) все кэши в память, берём буфер для приёма и сбрасываем все кэши там тоже и инвалидируем весь кэш для этого региона памяти. Т.к. мы хотим читать актуальные данные, что сохранил GPU, а не то, что в кэше. Эти буфера никто не имеет права трогать кроме нашего приложения, чтобы никакие данные не проскользнули в кэш. Вроде все условия соблюдены.
Тестируем. Процессор холодный и скоро замёрзнет, всё работает у нас быстро. Но через неделю приходит другая команда и говорят, что когда работает наш код, то их код, который тоже супер-оптимизированный работает не очень быстро. Ищем подводные камни: en.wikipedia.org/wiki/Goldmont — Automotive processors (Apollo Lake)
Размер кэша второго уровня всего 2Mb. Копируя буфера по 3Mb с частотой 60 раз в секунду мы просто уничтожаем весь кэш процессора, он замещается данными из нашего буфера, оставляя другие ядра с бесполезным содержимым кэша на борту, т.к. они никогда не будут к этой памяти обращаться. Вот и негативная сторона когерентности кэша, с Write-Combining (WC) такой проблемы бы не имели, зато грелся процессор. Открываем библию Intel® 64 and IA-32 Architectures Software Developer Manuals (ссылка была дана вверху) и читаем главу 10.4.6.2 Caching of Temporal vs. Non-Temporal Data. Не мы первые и не мы последние столкнулись с этой негативной стороной кэширования в многоядерных и многопроцессорных системах. Некоторые команды процессора имеют Non-Temporal свойство по отношению к кэшу, они имеют суффикс NT в имени своей мнемоники, в середине статьи упоминалась команда MOVNTDQA с обещанием объяснить её смысл в конце статьи. При работе этих команд процессор выделает минимальный временный кэш только для ускорения работы этих команд при копировании, но не загрязняет остальной кэш ненужными данными. Заменяем нашу оптимизированную memcpy() функцию опять на функцию, любезно предоставленную Mesa3D с использованием MOVNTDQA (ссылка в середине статьи) и вуаля. Кэш практически не тронут, другие ядра, которые обслуживают распознование и прочие подсистемы ADAS больше не тормозят и работают на пределе выжимая последную каплю производительности из кремния.
Это не единственная проблема, которую приходилось решать, связанную с кэшированием, но наиболее яркая и простая для понимания.
512 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів