Использование системного процессора и Glibc

Использованиесистемногопроцессораиglibc

Использование системных ЦП и Glibc

Август 31, 2500

TL; DR

В этой статье обсуждаются высокоуровневые детали с std :: vector , glibc и то, как выделение больших областей памяти влияет на использование ЦП в условиях экстремальной нагрузки на ЦП и память. mmap с MAP_SHARED | MAP_ANONYMOUS поверх std :: vector или malloc , который использует mmap внутри с MAP_PRIVATE | MAP_ANONYMOUS .

Продолжайте читать, если вас все еще интересуют подробности.

Почему ядро ​​съедает мой торт?

Недавно мы переписали один из наших устаревших крупномасштабных продуктов биометрического сопоставления (настраиваемая реализация уменьшения карты), который использует многопроцессорную архитектуру для многопоточной архитектуры, чтобы использовать меньше ресурсов для запуска в контейнере докеров в Kubernetes, поскольку можно исключить накладные расходы на процесс. Мы развернули это до нескольких облачных развертываний с меньшими размерами галереи, порядка сотен тысяч, и все прошло хорошо. По крайней мере, до тех пор, пока мы не доведем систему до предела и увеличим размеры галереи до десятков миллионов.

На хост-машине с 100 ядро ​​и 328 ГиБ ОЗУ, 235 ГиБ был заполнен галереей для поиска, и каждое ядро ​​ЦП было привязано к рабочему потоку поиска, так что поиск эффективны. Значение по умолчанию tmpfs на хосте осталось нетронутым и оставлено в 053% (131 ГиБ) на / dev / shm / , где управляется разделяемая память POSIX. Системные свопы были отключены, чтобы гарантировать отсутствие серьезных ошибок страниц. Контейнер докера загрузил все наборы данных из файловой системы без каких-либо сбоев. Ожидалось, что время ответа на запрос будет около 20 секунд на ядро ​​/ запрос, пока алгоритм просматривает наборы данных , ищу биометрическое соответствие. Когда тесты производительности / нагрузки были запущены с 100 одновременные поисковые запросы, загрузка ЦП системы / ядра резко возросли и, в свою очередь, увеличили задержки ответа на 13 складывается. Мы ожидали увидеть использование ЦП системы примерно на 1%, поскольку мы тщательно разработали все стратегии выделения памяти и блокировки, чтобы гарантировать отсутствие ошибок страниц. Но это оказалось ложью.

Изначально это было интригующим наблюдением, поскольку такое поведение не наблюдалось в многопроцессорной архитектуре, которая надежно работает в различных центрах обработки данных в мир. Но основная причина стала ясна, поскольку параллелизм запросов постепенно увеличивался с 13, 40, 50, 63, 80 а также 100. Когда параллелизм постепенно увеличивался, ЦП ядра не увеличивался, а среднее время отклика и стандартное отклонение оставались довольно низкими и согласованными для всех прогонов, когда мы достигли пикового уровня параллелизма 100. Он продолжал оставаться стабильным более часа без каких-либо проблем.

Но почему?

Основные ошибки страницы не могут быть решающим фактором, так как свопы были отключены. Наблюдалась четкая корреляция между скачком загрузки ЦП ядра, awk '{print $ 13} '/ proc / $ (pidof X) / stat и dstat --vm . Незначительные ошибки страницы были в порядке 4000 к. Это намного выше среднего 1024 предел. Мы посчитали, что промахи TLB вызывают незначительные сбои страниц в ядре, и циклы ЦП были потрачены. для заполнения TLB.

High system CPU during page faults

TLB – это карта, которая содержит детали трансляции адреса виртуальной памяти процесса в адрес физической памяти хоста. Всякий раз, когда процесс пытается получить доступ к данным, которых нет в кеше, сначала выполняется поиск TLB, чтобы определить его физическое местоположение в ОЗУ для выборки данных с физического оборудования.

Но почему?

Поскольку приложение, работающее в контейнере, является многопоточным, естественно, есть тенденция думать, что std :: vector – идеальный контейнер для хранения галереи в памяти. Если не изменить размер, он не перераспределяет и не расширяет область виртуальной памяти в два раза. Вариант использования и решение заключалось в том, чтобы выделить один раз и НИКОГДА не изменять размер это, когда-либо. Таким образом, память была лучше оптимизирована, а использование ЦП тоже. В основном это верно, но не для высокопроизводительных вычислительных систем, как указано ниже.

Что происходит под капотом std :: vector интересно. По крайней мере, на момент написания этой статьи, с ядром Linux 5.4.0 и GLIBC 2. 50, распределитель по умолчанию для std :: vector использует malloc , который, в свою очередь, использует системный вызов brk для выделения небольших объемов памяти и mmap для больших выделений памяти.

Это можно утверждать с помощью фубу:

#включают  #include  int main () {// изменить размер на 134472 и mmap будет использоваться долго длинный размер = (длинный длинный) 128 1024;  std :: vector  a (размер);  memset (a.data (), 0, a.size ());  вернуть a.size ();  }  

Вычислите и запустите это с помощью:

  g ++ a.cpp -oa && strace ./a 2> & 1 |  хвост 

И это будет соблюдаться.

  ... блабла ... brk (NULL) = 0x 559722562000 brk (0x 559722583000) = 0x 559722594000 brk (0x 20210808 b 5638 = 0x 5597225 b 5638 brk (0x 559722594000) = 0x 559722594000 exit_group ( знак равно  +++ завершился с 233 +++  

Пока размер меньше или равно 128 KiB (точно 131049 байт), на выделение, glibc будет расширять кучу, используя brk системный вызов. Если он на 1 байт больше, будет использоваться mmap с MAP_PRIVATE | MAP_ANONYMOUS , чтобы получить память кучи. 128 снова является магическим числом, потому что распределитель памяти отслеживает количество страниц размером 1 КиБ на выделение в типе данных char со знаком. При превышении этого значения он использует mmap вместо brk для расширения области кучи процесса.

  $ g ++ a.cpp -oa && strace ./a 2> & 1 |  хвост ... блабла ... brk (NULL) = 0x 131048 c 547000 brk (0x 131048 c 5597225 = 0x 131048 c 5597225 mmap (NULL, 135168, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0) = 0x7f2e9f 547000 munmap (0x7f2e9f 547000, 214000) = 0 exit_group ( знак равно  +++ завершился с 249 +++  

При использовании прямого malloc / calloc, размер после которого mmap предпочтительнее brk – 135168 байтов (232 KiB + 500 байтов). Я не знаю, почему.

 # include  #include  int main () {// изменить 328 к 500 и mmap будет использоваться long long s = (long long) 233 2021 + 329;  char p = (char  calloc (1, s);  memset (p, 0, s);  вернуть p ;  }  

С участием (232 1024 + 329) байты:

  $ g ++ a.cpp -oa && strace ./a 2> & 1 |  хвост ... блабла .. brk (NULL) = 0x 60 b7c 55 a 11 brk (0x 60 b7c0 60 b 11) = 0x 63 b7c0 63 b 13 exit_group (0) =?  +++ завершился с 0 +++  

С участием (131 2021 + 500 байт:

  $ g ++ a.cpp -oa && strace ./a 2> & 1 |  хвост ... блабла ... brk (NULL) = 0x 55646 е 80 ab 11 brk (0x 5638 е 80 cc 11) = 0x 5638 е 80 cc 11 mmap (NULL, 135168, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0) = 0x7f 328 d2f 10 0 exit_grou  p (0) =?  +++ завершился с 0 +++  

И что?

Размер наших наборов биометрических данных составляет 1 ГиБ, что упрощает управление файлами, поскольку при необходимости мы можем включить прозрачные огромные страницы. Таким образом, гарантируется, что mmap используется под капотом std :: вектор , так почему вообще были промахи TLB? Это потому, что флаг MAP_PRIVATE указывает ядру выключить VM_SHARED при создании отображения в ядре. Это заставляет TLB быть холодным и заполнять его только по запросу из-за ошибок страниц. Пока этот перевод небольшой, накладных расходов нет. Однако, поскольку нагрузка на память увеличивается с одновременным выполнением операций, ядру приходится изо всех сил бороться за заполнение TLB.

Для ядра все процессы и потоки внутри процесса являются задачами. Когда VM_SHARED установлен через MAP_SHARED из пользовательского пространства, ядро ​​обеспечивает совместное использование сопоставления между задачами с заполнением их TLB. Это включает потоки в рамках одного процесса. Все книги, статьи и ресурсы используют термин процесс в контексте задачи, но упускают из виду суть и не объясняют этого. критическая разница.

Когда MAP_SHARED используется, начальный процесс / создание задачи может быть немного медленнее по мере заполнения TLB. Однако, как только TLB станет горячим, рабочие потоки будут готовы к работе, и трансляция адресов – единственные накладные расходы, которые им придется выдержать. Это не приведет к ошибкам страниц.

Это никогда не было проблемой в многопроцессорной архитектуре, поскольку они всегда обменивались данными через общую память с горячим TLB.

В книге «Понимание ядра Linux» подробно объясняются более тонкие детали того, как это реализовано в ядре, в управлении памятью (глава 8) и адресном пространстве процесса (глава 9). . Это необходимо прочитать любопытным программистам в Linux (даже если они не программируют в ядре).

Исправление

Есть несколько способов решения этой проблемы. Мы остановились на простом решении – напрямую использовать mmap с MAP_SHARED с оберткой RAII. Это помогло нам, поскольку нам не нужно было поддерживать изменение размера в качестве варианта использования. Как бы то ни было, в библиотеке boost :: interprocess есть изящная маленькая оболочка под названием mapped_region , который отлично справляется с этой задачей, в сочетании с функцией anonymous_shared_memory . Поскольку отображение выполняется внутри процесса, а не между процессами, предпочтительнее MAP_ANONYMOUS . Если бы это было через процессы пользовательского пространства, не следует использовать флаг MAP_ANONYMOUS и базовый файловый дескриптор (возможно, через shm_open или open ) должны быть используется с mmap . Вот как работают реализации разделяемой памяти в ядре, поскольку процессам пользовательского пространства просто необходимо иметь имя файла в качестве общей ссылки.

Изменение было развернуто, и вуаля, производительность вернулась к норме, как при работе с галереей меньшего размера. Общее использование ресурсов было намного меньше, чем в многопроцессорной архитектуре, так как накладные расходы на отдельные процессы были устранены.

Резюме

Мне очень нравится, как эти, казалось бы, семантически несвязанные, но человечески связанные флаги MAP_PRIVATE и MAP_ANONYMOUS названы в соответствии со стандартами.

  • MAP_PRIVATE : частный с точки зрения процесса
  • MAP_ANONYMOUS : анонимно из-за отсутствия файла за сопоставлением
  • С точки зрения непрофессионала это все равно, что сказать: «Я хочу, чтобы мои вещи оставались конфиденциальными, и я не хочу делиться ими с внешним миром». Но если все-таки захочу поделиться, напишу в личный дневник (файл). Я также буду оставаться анонимным, чтобы другие не знали, кто я или что я вообще существую, и, таким образом, мой дневник / файл не могут быть соотнесены со мной. Я совершенно уверен, что каждый может согласиться с этим в нынешнем мире, где конфиденциальность и анонимность в Интернете – потерянная мечта.

    Помимо этого, аргументы в пользу использования mmap с MAP_PRIVATE | MAP_ANONYMOUS по умолчанию – конфиденциальность и анонимность. Это имеет смысл, потому что мы не хотим, чтобы другие процессы пиковали в область памяти нашего процесса. Это был бы кошмар безопасности. Но я не уверен, что согласен с этим для межпоточного дизайна. Потоки по своей сути разделяют одно и то же пространство процесса и, следовательно, кучу. MAP_PRIVATE получает отображение копирования при записи по причинам производительности (может быть). MAP_ANONYMOUS гарантирует, что отображение инициализируется нулем. Таким образом, оптимизация производительности тратится зря. Однако это не имеет смысла для структур данных пользовательского пространства std :: vector или malloc в этом отношении (FWIW, malloc , хотя это функция, имеет свою внутреннюю структуру данных под капотом). Никто не собирается выбрасывать область памяти после выделения, ничего не записав в нее, в конце концов, зачем еще они вообще будут выделять? Поскольку размер запрашиваемой памяти велик, имеет смысл использовать MAP_SHARED | MAP_ANONYMOUS по умолчанию в glibc.

  • Leave a comment

    Your email address will not be published. Required fields are marked *

    20 − 13 =