Недавно в нашем блоге появилась статья о NUMA-системах, и я хотел бы продолжить тему, поделившись своим опытом работы в Linux. Сегодня я расскажу о том, что бывает, если неправильно использовать память в NUMA и как диагностировать такую проблему с помощью счётчиков производительности.
Итак, начнем с простого примера:
Это простой тест, который в цикле суммирует элементы массива. Запустим его в несколько потоков на двухсокетном сервере, у которого в каждый сокет установлен четырехядерный процессор. Ниже приведен график, на котором мы видим времена выполнения программы в зависимости от числа потоков:
Мы видим, что время выполнения на восьми потоках всего в 1.16 раза короче чем на четырех потоках, хотя при переходе с двух на четыре потока прирост производительности заметно выше. Теперь сделаем простую трансформацию кода: добавим директиву распараллеливания перед инициализацией массива:
И соберем еще раз времена выполнения:
И вот, на восьми потоках произошло улучшение производительности почти в 2 раза. Таким образом, наше приложение практически линейно масштабируется на всём диапазоне потоков.
Итак, давайте разберемся, что же произошло? Каким образом простое распараллеливание цикла инициализации привело к почти двукратному приросту? Рассмотрим устройство двухпроцессорного сервера с поддержкой NUMA:
За каждым четырёхядерным процессором закреплен определенный объем физической памяти, с которым он общается через интегрированный контроллер памяти и шину данных. Такая связка процессор + память называется узел или нода (node). В NUMA-системах (Non Uniform Memory Access) доступ в память чужой ноды занимает намного больше времени, чем доступ в память своей ноды. Когда приложение в первый раз обращается к памяти, то происходит закрепление виртуальных страниц памяти за физическими. Но в NUMA-системах под управлением ОС Linux у этого процесса есть своя специфика: физические страницы, за которыми будут закреплены виртуальные, выделяются на той ноде, с которой произошло первое обращение. Это так называемый “first-touch policy”. Т.е. если с первой ноды произошло обращение к какой либо памяти, то виртуальные страницы этой памяти будут отображаться на физические, которые тоже будут выделены на первой ноде. Поэтому здесь важно правильно инициализировать данные, ведь от того как данные закрепятся за нодами будет зависеть производительность приложения. Если говорить о первом примере, то весь массив был проинициализирован на одной ноде, что привело к закреплению всех данных за первой нодой, после чего половина этого массива была считана другой нодой, а это и привело к ухудшению производительности.
Внимательный читатель должен был уже задаться вопросом «А разве выделение памяти через malloc не является первым доступом?». Конкретно в этом случае – нет. Дело вот в чем: при выделении больших блоков памяти в Linux, функция glibc malloc (а также calloc и realloc) по умолчанию вызывает сервисную функцию ядра mmap. Эта сервисная функция делает лишь отметки о количестве выделенной памяти, но физическое выделение происходит только при первом доступе к ним. Этот механизм реализуется через прерывания (exceptions) Page-Fault и Copy-On-Write, а также через маппирование на «нулевую» страницу (“zero” page). Кому интересны детали, могут почитать книгу «Understanding the Linux Kernel». А вообще, возможна ситуация, когда функция glibc calloc выполнит первый доступ к памяти для того, чтобы её «занулить». Но опять же, такое произойдет, если calloc решит вернуть пользователю ранее освобожденную память на куче (heap), а такая память уже будет существовать на физических страницах. Поэтому во избежании лишних головоломок рекомендуется использовать так называемые NUMA-aware менеджеры памяти (например TCMalloc), но это уже другая тема.
А теперь давайте ответим на главный вопрос этой статьи: «Как узнать правильно ли приложение работает с памятью в NUMA-системе?». Этот вопрос будет для нас всегда самым первым и главным при адаптации приложений для серверов с поддержкой NUMA, независимо от операционной системы.
Для ответа на этот вопрос нам понадобится VTune Amplifier, который умеет считать события для двух счётчиков производительности (performance counters): OFFCORE_RESPONSE_0.ANY_REQUEST.LOCAL_DRAM и OFFCORE_RESPONSE_0.ANY_REQUEST.REMOTE_DRAM. Первый счётчик считает количество всех запросов, данные для которых были найдены в оперативной памяти своей ноды, а второй – в памяти чужой ноды. Можно на всякий случай еще собрать счётчики для КЭШ’а: OFFCORE_RESPONSE_0.ANY_REQUEST.LOCAL_CACHE и OFFCORE_RESPONSE_0.ANY_REQUEST.REMOTE_CACHE. Вдруг окажется что данные находятся не в памяти, а в КЭШ’е процессора на чужой ноде?
Итак, запустим наше приложение без распараллеливания инициализации на восемь потоков под VTune и посчитаем количество событий для указанных выше счетчиков:
Мы видим, что поток, выполнявшийся на cpu 0, работал в основном со своей нодой. Хотя время от времени модуль vmlinux на этом ядре зачем-то заглядывал в чужие ноды. А вот поток на cpu 1, делал всё наоборот: только для 0.13% всех запросов данные нашлись в его собственной ноде. Здесь я должен пояснить, каким образом ядра закреплены за нодами. Ядра 0,2,4,6 принадлежат первой ноде, а ядра 1,3,5,7 – второй. Топологию можно узнать с помощью утилиты numactl:
numactl --hardware
available: 2 nodes (0-1)
node 0 cpus: 0 2 4 6
node 0 size: 12277 MB
node 0 free: 10853 MB
node 1 cpus: 1 3 5 7
node 1 size: 12287 MB
node 1 free: 11386 MB
node distances:
node 0 1
0: 10 20
1: 20 10
Обратите внимание, что здесь перечислены логические номера, в реальности же ядра 0,2,4,6 принадлежат одному четырехядерному процессору, а ядра 1,3,5,7 – другому.
Теперь посмотрим на значение счетчиков для примера с параллельной инициализации:
Картина почти идеальная, мы видим, что все ядра работают в основном со своими нодами. Обращения в чужие ноды, составляют не больше полпроцента от всех запросов, за исключением cpu 6. Это ядро примерно 4.5% всех запросов отправляет в чужую ноду. Т.к. обращение в чужую ноду занимает в 2 раза дольше времени чем в свою, то 4.5% таких запросов не сильно ухудшают производительность. Поэтому, можно сказать, что теперь приложение правильно работает с памятью.
Таким образом, используя эти счётчики вы всегда можете определить есть ли возможность ускорить приложение для NUMA-системы. На практике у меня были случаи когда правильная инициализация данных ускоряла приложения в 2 раза, причем в некоторых приложениях приходилось параллелить все циклы, немного ухудшая производительность для обычной SMP-системы.
Для тех, кому интересно, откуда берутся 4.5%, предлагаю пойти дальше. Процессор Nehalem и его потомки имеют богатый набор счётчиков для анализа активности системы памяти. Все эти счётчики начинаются с названия OFFCORE_RESPONSE. Может даже показаться, что их слишком много. Но если посмотреться внимательно, то можно заметить что все они являются комбинациями составных запросов и ответов. Каждый составной запрос или ответ состоит из базовых запросов и ответов, которые задаются битовой маской.
Ниже перечислены значения битовых масок для составных запросов и ответов:
Вот так формируется счётчик OFFCORE_RESPONSE_0 в процессоре Nehalem:
Давайте разберем, например, наш счётчик OFFCORE_RESPONSE_0.ANY_REQUEST.REMOTE_DRAM. Он состоит из составного запроса ANY_REQUEST и составного ответа REMOTE_DRAM. Запрос ANY_REQUEST имеет значение xxFF, что означает отслеживание всех событий: от чтения данных «по требованию» (бит 0, Demand Data Rd в таблице) до префетчеров КЭШ’а инструкций (бит 6, PF Ifetch) и остальной «мелочи» (бит 7, OTHER). Ответ REMOTE_DRAM имеет значение 20xx, что означает отслеживание запросов, данные для которых нашлись только в памяти чужой ноды (бит 13 L3_MISS_REMOTE_DRAM). Всю информацию по этим счётчикам можно найти на сайте intel.com документ «Intel 64 and IA-32 Architectures Optimization Reference Manual», раздел «B.2.3.5 Measuring Core Memory Access Latency».
Для того чтобы понять кто именно отправляет свои запросы в чужую ноду нужно разложить ANY_REQUEST на составные запросы: DEMAND_DATA_RD, DEMAND_RFO, DEMAND_IFETCH, COREWB, PF_DATA_RD, PF_RFO, PF_IFETCH, OTHER и собрать для них события по отдельности. Таким образом «виновник» был найден:
OFFCORE_RESPONSE_0.PREFETCH.REMOTE_DRAM
cpu 0: 6405
cpu 1: 597190
cpu 2: 2503
cpu 3: 229271
cpu 4: 2035
cpu 5: 190549
cpu 6: 19364266
cpu 7: 228027
Но почему prefetcher именно на 6 ядре заглядывал в чужую ноду, в то время как prefetcher’ы остальных ядер работали со своими нодами? Дело в том, что перед запуском примера с параллельной инициализацией, я дополнительно установил жесткую привязку потоков к ядрам следующим образом:
export KMP_AFFINITY=granularity=fine,proclist=[0,2,4,6,1,3,5,7],explicit,verbose
./a.out
OMP: Info #204: KMP_AFFINITY: decoding x2APIC ids.
OMP: Info #202: KMP_AFFINITY: Affinity capable, using global cpuid leaf 11 info
OMP: Info #154: KMP_AFFINITY: Initial OS proc set respected: {0,1,2,3,4,5,6,7}
OMP: Info #156: KMP_AFFINITY: 8 available OS procs
OMP: Info #157: KMP_AFFINITY: Uniform topology
OMP: Info #179: KMP_AFFINITY: 2 packages x 4 cores/pkg x 1 threads/core (8 total cores)
OMP: Info #206: KMP_AFFINITY: OS proc to physical thread map:
OMP: Info #171: KMP_AFFINITY: OS proc 0 maps to package 0 core 0
OMP: Info #171: KMP_AFFINITY: OS proc 4 maps to package 0 core 1
OMP: Info #171: KMP_AFFINITY: OS proc 2 maps to package 0 core 2
OMP: Info #171: KMP_AFFINITY: OS proc 6 maps to package 0 core 3
OMP: Info #171: KMP_AFFINITY: OS proc 1 maps to package 1 core 0
OMP: Info #171: KMP_AFFINITY: OS proc 5 maps to package 1 core 1
OMP: Info #171: KMP_AFFINITY: OS proc 3 maps to package 1 core 2
OMP: Info #171: KMP_AFFINITY: OS proc 7 maps to package 1 core 3
OMP: Info #147: KMP_AFFINITY: Internal thread 0 bound to OS proc set {0}
OMP: Info #147: KMP_AFFINITY: Internal thread 1 bound to OS proc set {2}
OMP: Info #147: KMP_AFFINITY: Internal thread 2 bound to OS proc set {4}
OMP: Info #147: KMP_AFFINITY: Internal thread 3 bound to OS proc set {6}
OMP: Info #147: KMP_AFFINITY: Internal thread 4 bound to OS proc set {1}
OMP: Info #147: KMP_AFFINITY: Internal thread 5 bound to OS proc set {3}
OMP: Info #147: KMP_AFFINITY: Internal thread 6 bound to OS proc set {5}
OMP: Info #147: KMP_AFFINITY: Internal thread 7 bound to OS proc set {7}
Согласно этой привязке первые четыре потока работают на первой ноде, а вторые четыре потока – на второй. Отсюда видно, что 6-ое ядро – это последнее ядро принадлежащее первой ноде (0,2,4,6). Обычно prefetcher всегда пытается закачать память с упреждением, которая находится далеко впереди (или позади, зависит от направления, в котором программа обращается к памяти). В нашем случае prefetcher шестого ядра закачивал память, которая находилась впереди той, с которой в тот момент работал поток Internal thread 3. Вот здесь то и произошло обращение в чужую ноду, так как впереди стоящая память частично принадлежала первому ядру чужой ноды (1,3,5,7). А это и привело к появлению 4.5% обращений в чужую ноду.
Замечание: тестовая программа была собрана компилятором Intel с опцией –no-vec, чтобы получить скалярный код вместо векторного. Это было сделано с целью получения «красивых данных» для облегчения понимания теории.
Автор: nikolai_serdyuk