Педальку в пол, или как ещё ускорить CPU-bound приложение?

TL; DR:


  • Переложив секции кода и данных программы на большие страницы можно существенно ускорить приложение (у нас получилось до +10%) не трогая исходный код.
  • Можно быстро проверить ничего не перекомпилируя, детали здесь.
  • Финальное решение оперирует «классическими» большими страницами (не transparent huge pages), поэтому в какой-то степени его можно назвать дальнейшим развитием libhugetlbfs.

Если вы спросите у инженера как ускорить вашу программу, то ответ будет зависеть от того, в какой области он специализируется:


  • системный архитектор откроет документацию на ваш продукт и будет искать самое слабое звено (компонент — бутылочное горлышко, заменив который система должна получить второе дыхание)
  • SDE сразу попросит доступ к исходному коду, пропадет из поля зрения на пару месяцев, анализируя асимптотики используемых алгоритмов — может быть ребята где-то пропустили «квадрат» или того похуже?
  • SRE станет профилировать ключевые процессы системы и как они взаимодействуют с ядром ОС, как используется память, где и сколько потребляется: perf top / perf stat / perf record / perf report / jemalloc profiler. А может быть pidstat, vmstat, sar? strace/gdb? Если есть новое Linux ядро под рукой, то набирает бешеную популярность eBPF: bcc в руки, и вперед! На выходе список наиболее тяжелых функций, узкие места ОС (сеть, диски, память?).
  • разработчик компиляторов откроет для вас дивный новый мир генерации кода с использованием профиля выполнения: PGO / AutoFDO / BOLT. Предложит LTO для усиления эффектов этих технологий. И, о чудо, оно действительно генерирует значительно более быстрые программы, особенно сильно видно на архитектурах не x86. Применив все это добро правильно, можно серьезно улучшить производительность, не трогая исходный код вообще. Привлекательно, не правда ли?
  • hardware специалист приоткроет двери NUMA-aware архитектур. Что греха таить — мы уже очень давно используем сервера с NUMA, а все наивно верим что все процессоры одинаковые, а RAM общая. Мммм, «Random access memory» — термин остался с прошлого века, сейчас это, прямо скажем, обман. Набор L1/L2/L3 + NUMA + RAM — чем дальше от процессора, выполняющего Ваш код, тем все дольше доступ, тем сложнее синхронизация. Забудьте о гигабайтах памяти, если Вам нужна производительность, представьте, что ваша память простая, предсказуемая, с последовательным доступом, эксклюзивная для потока, и её не так уж и много (пара мегабайт?). Вряд ли сходу вы сможете все это применить для своего продукта, но попытаться все же стоит.
  • разработчик операционных систем, глубоко вздохнув, расскажет о бремени обратной совместимости и петабайтах уже написанных, отлаженных и работающих как часы приложений, а потом Вашему взору откроются новые API асинхронного доступа к современным дискам (libaio, io-uring), ориентированные на облака планировщики задач (linux kernel >= 4) и оптимизации технологии виртуального адресного пространства.

Спектр доступных инструментов и технологий довольно велик, сегодня я расскажу про опыт применения больших страничек в лаборатории баз данных Huawei на примере MySQL сервера: здесь описывается попытка оптимизировать доступ к виртуальному адресному пространству и как с её помощью улучшить утилизацию процессорного времени.

Рассказа про технологию виртуальной памяти, как это сделано внутри Linux, и что такое MMU и TLB, здесь не будет. Есть огромное количество официальных статей, а также зарекомендовавших себя полезных книжных изданий, где это детально описывается. Что-то подзабыли — освежите в Вашей любимой настольной книге по операционным системам.

Разумеется, технология больших страниц не нова (сколько десятилетий прошло с момента выхода Linux 2.6.16?), но почему-то есть не так много программных продуктов, которые дейтвительно используют большие странички хоть как-то. Например, в MySQL большие страницы можно подключить только для внутреннего кеша страниц B-дерева («innodb_buffer_pool»), причем Oracle предлагает это делать через довольно устаревший SystemV shared memory механизм, а ОС требуется дополнительная специфическая конфигурация.

А где же используют большие странички для собственно кода и данных приложения? .text/.data/.bss располагаются в стандартном маппинге в адресном пространстве процесса, его тоже можно положить на большие страницы. Если кода сгенерировано много, обращение к сегменту кода выполняется довольно часто, производительность также страдает от iTLB/dTLB кеш-промахов. Думаю, можно пересчитать по пальцам, где такой подход применяется, хотя попытки появляются с завидной регулярностью (что несомненно радует):

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

Теория гласит: чем больше страница виртуального адресного пространства, тем большее адресное пространство может быть адресовано с помощью фиксированного набора записей, помещающихся в TLB буфер процессора. Как только количество записей превышает TLB буфер, появляются дополнительные накладные расходы на трансляцию виртуального адреса в физический. Кстати, TLB буферов может быть несколько, например, L1 и L2. В этой статье презентован бенчмарк, показывающий влияние первого и второго уровня TLB с привязкой к спецификации испытуемого процессора. Кроме того, разные архитектуры поддерживают разный набор больших страниц (например, x86_64: 2M, 1G; ppc64: 16М; AArch64: 64K, 2M, 512M, 16G (зависит от модели, а иногда и конфигурации ядра ОС)), поэтому выбор в каждом случае зависит от конкретных целей и задач. Для MySQL 8.0 размер сегментов кода и данных, запакованных в ELF формат, составляет около 120–140М, кроме того в Huawei Cloud мы поддерживаем только 2 архитектуры: x86_64 и AArch64, поэтому выбор пал на стандартные 2М большие страницы.

Теперь какую технологию больших страниц выбрать? Есть 2 основных типа больших страниц в Linux:


  • classic hugepages
  • transparent hugepages

Здесь на ум приходит старый добрый Морфеус, и его синяя и красная таблетки:

morpheus


  • Синяя таблетка (transparent huge pages) — вы включаете технологию прозрачных больших страниц, сообщаете Linux ядру, какой маппинг выровнен корректно по границе большой странички (в нашем случае 2М) и рекомендуете его использовать. Всё. После «вы просыпаетесь в своей постели с твердой уверенностью, что все это был сон».
  • Красная таблетка (classic huge pages) — вы «копаете» дальше и «узнаете, насколько глубока кроличья нора».

«Cъесть ли синюю таблетку»? Опыт коллег по использованию THP в типовых базах данных нас сильно обеспокоил. В чем же подвох?


  • Дефрагментация физической памяти. Не замечали такой интересный процесс «khugepaged»? Да-да, именно он может притормозить вдруг ваше приложение, даже если вы никогда не планировали использовать какие-то там другие страницы, но ваш процесс останавливается, потому что его кодовый сегмент переносится в другое место физической памяти, чтобы создать побольше больших страниц для своего потребителя. Да и потребитель (к примеру, MySQL сервер) может испытывать случайные скачки TPS/latency в эти моменты времени.
  • Непредсказуемость поведения. Какова светлая мечта любого DBA? Правильно, чтобы система работала предсказуемо, понятно, быстро и просто. THP — это оптимизация работы ядра, она может работать и давать результаты, а может не работать или временно не работать, и только ядро конкретной версии знает, почему так происходит. Оценить изменение производительности — уже достаточно сложно решаемая задача. Оценить прирост от оптимизации ядра, которая срабатывает по своему заложенному алгоритму — на порядок сложнее, разве только вы специалист ядра Linux и регулярно «комитите» в ядро, но тогда вряд ли вы это читаете:)
  • Свопинг. На старых Linux ядрах, когда большая страница выгружается в файл подкачки, то она разбивается на множество стандартных страниц, когда загружается обратно — снова сливается воедино. Разумеется, этот процесс бьет по производительности системы. Классические страницы выделяются в RAM перманентно, и в swap не выгружаются. На момент написания статьи я видел патчи в ядро, которые решают этот вопрос.
  • Рост потребления памяти. Случается при неудачном динамическом выделении в коде конечного приложения, когда отдельно нужно хранить данных пару килобайт, но выделяется каждый раз для этого одна полная страничка памяти (например, 2М). Знаю, что данный симптом присущ и классическим большим страничкам, но прямого контроля над THP программист не имеет. Согласен, пунктик спорный, однако в статьях упоминается регулярно, поэтому решил его также добавить.

Надо все же признать, что разработчики ядра Linux активно улучшают эту технологию, и в ближайшем будущем ситуация может кардинальным образом измениться (возможно, будущее уже наступило). Не даром такие техно-гиганты как Google/Facebook/Intel в своих решениях предлагают использовать THP и отдать ядру решающий голос. Увы, для нас был важен результат здесь и сейчас, и, кроме того, процесс принятия на вооружение новых Linux ядер в Huawei достаточно сложный, ответственный и занимает много времени.


Итак, мы «проглотили красную таблетку».

Думаю, каждая команда, которая пробовала перемапливать сегменты кода и данных на большие страницы, начинала свой путь с боевого крещения огнем, а именно с библиотеки libhugetlbfs. Сей комбайн создавался на заре внедрения технологии больших страниц в народные массы, поддерживает очень старые ядра (2.6.16) и toolchain’ы (да-да, вот эти линкерные скрипты создавались для них: libhugetlbfs/ldscripts), а также умеет работать не только под Linux, а после прочтения имплементации создается стойкое ощущение, что библиотека активно используется (или использовалась) во встраиваемых системах малой мощности с очень небольшим количеством памяти на борту. В общем, что воду лить? Прикоснуться к истории и почерпнуть мудрости можно на сайте проекта.

Разумеется, это решение взлетело не сразу, однако эффект ускорения был очевидным. MySQL сервер смог устойчиво выдавать на 10% больше TPS (transactions per second) в OLTP PS (point select) на 1vCPU инстансе (эмулятор виртуальной машины на 1 CPU с использованием Linux cgroups, x86_64). iTLB-misses упал в разы. На AArch64 платформе прирост производительности был ещё больше. Наша команда более детально изучила эффект перемапливания .text/data/bss сегментов по отдельности и всех вместе, результат можно представить одной картинкой (процессор AArch64: Huawei Kunpeng 920):

chart

Конечно же, это был «усиленный» CPU-bound бенчмарк (острая нехватка ресурсов процессора), но +10% определенно стоили того, чтобы продолжать исследование. A походу проведения тестов стали отчетливо видны следующие ограничения:


  • Включение ASLR на сервере, где работал MySQL (дефолтная сборка с PIE), приводило к SIGSEGV. Анализ выявил явный баг со стороны libhugetlbfs, и ваш покорный слуга с превеликим удовольствием сей баг зарепортил (сразу с фиксом): https://github.com/libhugetlbfs/libhugetlbfs/issues/49. Спустя год мне также радостно ответили: «не воспроизводится», даже не взглянув на патч. Я в печали …
  • Ограничение на количество сегментов, которые можно перемапить — 3. Думаю, дело опять же в истории, раньше GNU BFD линкер генерировал только 2 ELF сегмента для загрузки динамическим линковщиком: код (r-x: чтение/выполнение) и данные (rw-: чтение/запись). Потом требование безопасности сделало его немного умнее (выделение сегмента для констант: r--), а разработчики, от греха подальше, выставили такую конфигурацию дефолтной. В итоге новый линкер создает 4 сегмента (r-x, r--, r-x, rw-: он все же недостаточно умный, чтобы сделать 3 сегмента, поэтому получаем 4), в итоге библиотека не перемапливает 1 финальный RW сегмент, который обычно является самым объемным.
  • Перемапливание финального сегмента автоматически отмапливает HEAP сегмент. Происходит это втихую, без каких-либо внешних проявлений, системный вызов brk просто перестает работать. Влияет только на стандартный аллокатор из glibc, который использует brk для аллокаций до 128К, а для всего остального — mmap; после сего несчастья glibc переключается на mmap для любых аллокаций. Доподлинно неизвестно, как это влияет на производительность приложения и системы в целом: если у вас есть идеи — делитесь в комментариях. Не влияет на jemalloc, который всю свою память выделяет через mmap.
  • Нет как таковой интеграции в целевое приложение — вся работа выполняется в конструкторе динамической библиотеки без каких-либо сообщений об ошибках. Если все плохо, приложение не стартует, а разбор полетов занимает много времени.
  • Используется специализированная файловая система hugetlbfs ядра Linux, что означает, что, как минимум, нужно примонтировать её с правильными параметрами, плюс обеспечить приложению корректные права доступа. В облаках, на виртуальных машинах, эта зависимость создает дополнительные проблемы, особенно учитывая тот факт, что начиная с Linux 2.6.32 можно сразу создать анонимный маппинг на больших страницах (системный вызов mmap). Ярмо обратной совместимости с Linux 2.6.16.
  • Приложение должно быть по-особому слинковано (конкретные флаги линкера: common-page-size=2M max-page-size=2M). Это сделано для обеспечения безопасности, поэтому не скажу, что это «ограничение» в прямом смысле слова, скорее обязательная рекомендация для финального деплоя в production, однако для тестового запуска для первоначальной оценки изменения производительности в любом случае требуется пересобрать целевое приложение — это не удобно.

Часть проблем критичные, иными словами не production-ready. Ох…

facepalm


Засучив повыше рукава и набрав побольше воздуха в легкие, я начал медленное и основательное погружение в libhugetlbfs, дабы сделать серверный аналог для нашего MySQL, лишенного всех перечисленных недостатков, а также интегрировав в дальнейшем это решение в код проекта.

Сразу оговорюсь, рассматривается вариант Linux 64-bit / программа в ELF формате / установлена glibc.

Чтобы расставить все точки над «и», стоит затронуть алгоритм запуска типового приложения в ОС Linux. Например, что скрывается за конкретным системным вызовом execve? Опять же, стоит отметить про уже существующие шикарные статьи с повышенной детализацией конкретных функций и вызовов в glibc / Linux kernel. Далеко ходить не будем, здесь отслеживается каждый шаг запуска GNU утилиты ls в интерпретаторе bash.

Из этого обилия технической информации нас сейчас интересует несколько моментов:


  • Вызов execve для исполняемого ELF файла приводит к вызову load_elf_binary в fs/binfmt_elf.c в ядре Linux
  • load_elf_binary:
    • разбирает ELF файл, достает из него сегменты данных и кода
    • создает маппинг для кода и данных приложения, потом инициализирует heap сегмент сразу за этими маппингами
    • примапливает VDSO сегмент
    • вычитывает текущий интерпретатор для ELF файла (обычно это динамический линковщик glibc), и загружает его в память (код и данные DSO объекта)
  • Linux ядро выполняет еще некоторые служебные функции, потом всю информацию о созданных маппингах сохраняет на стеке, после передает управление динамическому линковщику glibc (или напрямую программе, если интерпретатор не указан, а программа слинкована статически)
  • Динамический линковщик:
    • инициализирует список текущих маппингов (регистрирует те, что уже созданы ядром)
    • вычитывает список DSO, от которых зависит приложение
    • выполняет поиск по системе и загрузку всех необходимых DSO (результат подключается к списку текущих маппингов)
    • выполняет конструкторы в каждом DSO, а также в основной программе
    • передает выполнение функции main основного приложения

Итого, получаем, что список маппингов нашей программы можно получить у:


  • ядра Linux,
  • библиотеки выполнения glibc,

a описание этих маппингов лежит в ELF файле.

Ядро Linux публикует маппинги приложения в /proc/$pid/smaps (детальный список) и /proc/$pid/maps (короткий список). Пример короткого списка с Ubuntu 20.04 (kernel 5.4):

$ cat /proc/self/maps
555555554000-555555556000 r--p 00000000 08:02 24117778                   /usr/bin/cat
555555556000-55555555b000 r-xp 00002000 08:02 24117778                   /usr/bin/cat
55555555b000-55555555e000 r--p 00007000 08:02 24117778                   /usr/bin/cat
55555555e000-55555555f000 r--p 00009000 08:02 24117778                   /usr/bin/cat
55555555f000-555555560000 rw-p 0000a000 08:02 24117778                   /usr/bin/cat
555555560000-555555581000 rw-p 00000000 00:00 0                          [heap]
7ffff7abc000-7ffff7ade000 rw-p 00000000 00:00 0
7ffff7ade000-7ffff7dc4000 r--p 00000000 08:02 24125924                   /usr/lib/locale/locale-archive
7ffff7dc4000-7ffff7dc6000 rw-p 00000000 00:00 0
7ffff7dc6000-7ffff7deb000 r--p 00000000 08:02 24123961                   /usr/lib/x86_64-linux-gnu/libc-2.31.so
7ffff7deb000-7ffff7f63000 r-xp 00025000 08:02 24123961                   /usr/lib/x86_64-linux-gnu/libc-2.31.so
7ffff7f63000-7ffff7fad000 r--p 0019d000 08:02 24123961                   /usr/lib/x86_64-linux-gnu/libc-2.31.so
7ffff7fad000-7ffff7fae000 ---p 001e7000 08:02 24123961                   /usr/lib/x86_64-linux-gnu/libc-2.31.so
7ffff7fae000-7ffff7fb1000 r--p 001e7000 08:02 24123961                   /usr/lib/x86_64-linux-gnu/libc-2.31.so
7ffff7fb1000-7ffff7fb4000 rw-p 001ea000 08:02 24123961                   /usr/lib/x86_64-linux-gnu/libc-2.31.so
7ffff7fb4000-7ffff7fb8000 rw-p 00000000 00:00 0
7ffff7fc9000-7ffff7fcb000 rw-p 00000000 00:00 0
7ffff7fcb000-7ffff7fce000 r--p 00000000 00:00 0                          [vvar]
7ffff7fce000-7ffff7fcf000 r-xp 00000000 00:00 0                          [vdso]
7ffff7fcf000-7ffff7fd0000 r--p 00000000 08:02 24123953                   /usr/lib/x86_64-linux-gnu/ld-2.31.so
7ffff7fd0000-7ffff7ff3000 r-xp 00001000 08:02 24123953                   /usr/lib/x86_64-linux-gnu/ld-2.31.so
7ffff7ff3000-7ffff7ffb000 r--p 00024000 08:02 24123953                   /usr/lib/x86_64-linux-gnu/ld-2.31.so
7ffff7ffc000-7ffff7ffd000 r--p 0002c000 08:02 24123953                   /usr/lib/x86_64-linux-gnu/ld-2.31.so
7ffff7ffd000-7ffff7ffe000 rw-p 0002d000 08:02 24123953                   /usr/lib/x86_64-linux-gnu/ld-2.31.so
7ffff7ffe000-7ffff7fff000 rw-p 00000000 00:00 0
7ffffffde000-7ffffffff000 rw-p 00000000 00:00 0                          [stack]
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0                  [vsyscall]

Смотрим список программных сегментов в ELF заголовке:

$ readelf -Wl /bin/cat

Program Headers:
  Type           Offset   VirtAddr           PhysAddr           FileSiz  MemSiz   Flg Align
  PHDR           0x000040 0x0000000000000040 0x0000000000000040 0x0002d8 0x0002d8 R   0x8
  INTERP         0x000318 0x0000000000000318 0x0000000000000318 0x00001c 0x00001c R   0x1
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD           0x000000 0x0000000000000000 0x0000000000000000 0x0016e0 0x0016e0 R   0x1000
  LOAD           0x002000 0x0000000000002000 0x0000000000002000 0x004431 0x004431 R E 0x1000
  LOAD           0x007000 0x0000000000007000 0x0000000000007000 0x0021d0 0x0021d0 R   0x1000
  LOAD           0x009a90 0x000000000000aa90 0x000000000000aa90 0x000630 0x0007c8 RW  0x1000
  DYNAMIC        0x009c38 0x000000000000ac38 0x000000000000ac38 0x0001f0 0x0001f0 RW  0x8
  NOTE           0x000338 0x0000000000000338 0x0000000000000338 0x000020 0x000020 R   0x8
  NOTE           0x000358 0x0000000000000358 0x0000000000000358 0x000044 0x000044 R   0x4
  GNU_PROPERTY   0x000338 0x0000000000000338 0x0000000000000338 0x000020 0x000020 R   0x8
  GNU_EH_FRAME   0x00822c 0x000000000000822c 0x000000000000822c 0x0002bc 0x0002bc R   0x4
  GNU_STACK      0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW  0x10
  GNU_RELRO      0x009a90 0x000000000000aa90 0x000000000000aa90 0x000570 0x000570 R   0x1

DSO зависимости:

$ ldd /bin/cat
        linux-vdso.so.1 (0x00007ffff7fce000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007ffff7dba000)
        /lib64/ld-linux-x86-64.so.2 (0x00007ffff7fcf000)

Анализ /proc/$pid/maps:


  • libc.so.6 — это libc-2.31.so
  • ld-linux-x86–64.so.2 — это ld-2.31.so
  • linux-vdso.so.1 — это [vdso], виртуальная DSO поставляемая ядром для ускорения работы четырех (для x86_64) системных вызовов, подробнее здесь
  • [vvar] и [vsyscall] — устаревшая имплементация [vdso] (ядро держит обратную совместимость)
  • [heap] и [stack] — здесь все понятно
  • /usr/bin/cat — это как раз LOAD сегменты из результатов readelf, сдвинутые на 0x555555554000 ядром.

Кстати, можно заметить, что LOAD сегментов у нас 4, а маппингов 5. Все дело в GNU_RELRO технологии (и снова безопасность!): по этим адресам располагается PLT таблица (1 страница, 4К). Её заполняет динамический линковщик, а когда дело сделано, сбрасывает на ней право записи. Теперь, если приложение пытаются взломать путем подмены PLТ адреса какой-нибудь широко известной функции (например, printf@plt), переписать ничего не получится, ибо ядро пришлёт SIGSEGV. Проверяем адреса GNU_RELRO сегмента:


  • 0×55555555e000 — 0×55555555f000: 4K (начало/конец маппинга, одна системная 4К страница)
  • 0×555555554000 + 0xaa90 = 0×55555555ea90 («ядерный» сдвиг плюс адрес начала GNU_RELRO сегмента)
  • 0×55555555ea90 & (~(0×1000 — 1)) = 0×55555555e000 (выравниваем предыдущий результат по границе 4К страницы => получаем начало маппинга)
  • 0×55555555f000 — 0×570 = 0×55555555ea90 (от конца маппинга отнимаем размер GNU_RELRO сегмента, получаем, с поправкой на выравнивание 4К, начало маппинга)
  • Цифры сходятся, и это хорошо!

Беглая справка по выводу readelf:


  • Offset = offset in ELF file
  • VirtAddr = virtual address in application address space
  • PhysAddr = physical address (не использовал это поле, интересно когда это нужно?)
  • FileSiz = размер данных внутри ELF файла
  • MemSiz = FileSiz + (область памяти, которую нужно занулить, используется для .bss)

С точки зрения glibc получить все маппинги приложения по ходу его выполнения можно с помощью функции dl_iterate_phdr: справка и небольшая заметка — через этот интерфейс вы получите честные 4 LOAD сегмента, ровно как и в выводе readelf.


Итого, вооружившись всем вышеперечисленным, выдвигаемся к своей цели — перемапить LOAD сегменты.

Нацеливаемся на большие страницы размером 2М (не THP), используем AArch64 или X86_64.

В основу первой версии elfremapper (так я решил наречь своё создание) положим функцию remap_segments из libhugetlbfs и чтобы сделать жизнь проще, сделаем нашу библиотечку статической. Смотрим в libhugetlbfs и делаем аналогично:


  1. Загрузка текущих LOAD сегментов используя dl_iterate_phdr (спасибо тебе, glibc, за точность, пунктуальность и беспристрастность в представлении данных: никакой дополнительной магии с GNU_RELRO)
  2. Проверка: сегменты не пересекаются с поправкой на выравнивание в 2М
  3. Дополнительно выравниваем сегменты в случае, если включен ASLR (в этом случае, сегменты имеют сдвиг 0×555555554000 и дополнительный рандомный сдвиг, который генерируется ядром каждый раз при запуске приложения — и эти сдвиги, разумеется, выравниваются ядром по границе 4К)
  4. Выделяем большие странички используя специальную файловую систему hugetlbfs (для каждого нашего маппинга кода и данных — свою копию, доступ MAP_PRIVATE)
  5. Копируем туда все наши маппинги по очереди: открыли файл на hugetlbfs, сделали mmap, получили виртуальный базовый адрес от ядра, скопировали, сделали munmap.
  6. Оставляем файловые дескрипторы открытыми для последующих операций.
  7. Проверочка — данные действительно скопировались? Мапим первый же файловый дескриптор от hugetlbfs, куда только что переносили данные — и — там пусто!

Что ж, читаем более детально реализацию libhugetlbfs, смотрим в документацию ядра, и приходим к выводу, что MAP_PRIVATE маппинг, созданный с помощью hugetlbfs, теряется после вызова munmap (он же PRIVATE!), при этом неважно, в каком состоянии остался соответствующий файловый дескриптор. Выдержка из man mmap:

MAP_PRIVATE     Create a private copy-on-write mapping. Updates to the mapping are not visible to other processes mapping the same file, and are not carried through to the underlying file. It is unspecified whether changes made to the file after the mmap() call are visible in the mapped region.

А libhugetlbfs использует MAP_SHARED! Хорошо, скрипя сердцем, делаем маппинги MAP_SHARED, открытые файлы тут же удаляем при помощи unlink из файловой системы. Продолжаем:


  • Проверка: данные действительно скопировались, держим открытыми файловые дескрипторы (файлы-то мы удалили!)
  • Отмапливаем наши текущие маппинги кода и данных — и — получаем SIGSEGV на следующей за munmap строчке кода.

Как так??? libhugetlbfs делает munmap и не падает, а у нас так же не работает… Думаем. Думаем. Ещё раз думаем.

Когда выполняется код, он зачитывается CPU из ровно такого же маппинга, как и все остальные. Единственное отличие — этот маппинг имеет флаг права выполнения. Получается, как только мы отмапили сегмент кода, который выполняем, чтение следующей ассемблерной инструкции привело к обращению к адресному пространству, которое не принадлежит нашему процессу, и вполне резонно мы получаем SIGSEGV. Почему же не падает libhugetlbfs? Дело в том, что это DSO и, естественно, имеет свой отдельный маппинг, который остается нетронутым — мы перемапливаем только LOAD сегменты основного процесса.

Что делать? Ещё раз думаем… Читаем man mmap:

MAP_FIXED     Don't interpret addr as a hint: place the mapping at exactly that address. addr must be suitably aligned: for most architectures a multiple of the page size is sufficient; however, some architectures may impose additional restrictions. If the memory region specified by addr and len overlaps pages of any existing mapping(s), then the overlapped part of the existing mapping(s) will be discarded. If the specified address cannot be used, mmap() will fail.

Так-так, значит, если мы сделаем MAP_FIXED на существующий маппинг, то ядро само его отмапит. Интересно! Что если зайти в системный вызов mmap со старого маппинга, а выйти на новом маппинге? Если виртуальные адреса останутся прежними, тогда ничего не должно измениться. Проверяем:


  • Не отмапливаем текущие маппинги кода и данных, берем открытые файловые дескрипторы (указывают на hugetlbfs) и делаем MAP_SHARED + MAP_FIXED маппинг поверх существующих. Работает!
  • Проверяем /proc/$pid/maps — вместо имени нашего приложения напротив LOAD сегментов мы видим наши файлики, созданные в hugetlbfs, например, /dev/hugepages/g4PcpN (deleted) (точка монтирования /dev/hugepages, файлики создавались с помощью mktemp)

Справочка: монтирование hugetlbfs (если не подмонтировано) и выделение страничек статически:

$ mkdir /dev/hugepages
$ mount -t hugetlbfs -o pagesize=2M none /dev/hugepages
$ sudo bash -c "echo 100 > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages"

Суммируем:


  • Это подход, при котором перемапливающий код слинкован статически (перемапливает сам себя)
  • Мы использовали MAP_SHARED для сегментов кода и данных. «Будут последствия» — вероятно, подумали вы, и вы правы!
  • Проверяем сколько страниц реально потребляется — оказывается, что цифры здесь сходятся один в один, сколько нужно было страниц (например, 67), ровно столько страниц будет задействовано системой (nr_hugepages — free_hugepages). Это очень важный пункт, так как в случае, если нам не хватит больших страниц, нужно аккуратно сообщить об ошибке, и в общем случае переключиться на использование обычных — т.е. вернуть все как было на место.

Прописываем алгоритм с учётом обработки out of memory (нехватки больших страниц):


  1. открываем файловый дескриптор на hugetlbfs;
  2. выделяем «большую» память с помощью mmap (передаем файловый дескриптор);
  3. здесь проверяем успешен ли mmap, если нет, сообщаем об ошибке, возвращаем все на место;
  4. копируем наш рабочий сегмент в выделенную память;
  5. отмапливаем «большую» память, оставляем открытым файловый дескриптор;
  6. делаем финальный mmap (fixed|shared) поверх существующего рабочего маппинга;
  7. этот системный вызов никогда не падает по нехватке памяти, так как маппинг shared (нет необходимости делать резервирование страниц в ядре), а вся «большая» память уже выделена на шаге 2 и проверена на корректность на шаге 3.

Чем чревато MAP_SHARED под маппингом кода/данных?


  1. Перестаёт работать fork. Нет, он работает корректно, но не копирует shared маппинги между потомком и родителем (что логично), это в свою очередь приводит к хаотичным непредсказуемым ошибкам в связи с гонками при доступе к одной и той же памяти из потомка и родителя.
  2. Перестают работать новые версии gdb: gdb attach, корректная загрузка core файлов. При этом старые версии работают штатно, что интересно, но дальше наша команда в эту сторону не копала.

Есть ещё одна глобальная проблема, на которой стоит остановиться отдельно: perf перестает резолвить символы. В итоге perf top/perf record отображают россыпь неагрегированных друг с другом адресов. Увы и ах, perf использует для загрузки символов ELF файлы, указанные в том же самом файлике /proc/$pid/maps, который изменился. К счастью, с этой напастью можно бороться штатными средствами утилиты perf. В своё время, когда на свет появились JIT-компиляторы (например, в таких популярных языках как Java или Python), в perf появился интерфейс для JIT кода: если утилита не может загрузить символы, она пытается загрузить их из файла /tmp/perf-$pid.map, который имеет предельно простой сsv формат (3 колонки: адрес начала, длина, имя символа). Итого, данная проблема закрывается так:

Как сделать текущее решение лучше? Заглядываем в libhugetlbfs, там финальный mmap выполняется с флажком MAP_PRIVATE|MAP_FIXED (пункт 6 алгоритма). Хорошо, пробуем, радуемся (fork/gdb в строю!), выкладываем в среду предрелизного нагрузочного тестирования, и спустя 3 недели работы продукт падает с SIGSEGV и нечитаемой полупустой «коркой».

Детальный анализ:


  • MAP_PRIVATE приводит к двойному потреблению больших страниц. В момент финального mmap ядро копирует все shared страницы: это логично, срабатывает copy-on-write + reservation. Как минимум, есть пик двойного потребления памяти в процессе работы алгоритма, хотя в моём случае память не вернулась в систему даже после закрытия всех файловых дескрипторов на hugetlbfs.
  • Вторая волна выделения памяти приходится на финальный mmap, и если в этот момент большие страницы заканчиваются, mmap падает, и не просто падает, а с крайне неприятным сайд-эффектом: ядро отмапливает все перекрывающиеся сегменты, и если случается ошибка (нет памяти), то на место маппинги не возвращаются. Наблюдаем аналогичную munmap ситуацию — секция с кодом покидает адресное пространство процесса — и здравствуй, SIGSEGV!

Думаем! Думаем! Усиленно думаем!

libhugetlbfs данную ситуацию никак не обрабатывает — если все плохо, приложение завершается сигналом SIGABORT. Продукты Google/Facebook/Intel используют THP, но при этом активно пользуют mremap. Что если использовать его для больших страниц? Выделить private маппинг, но не отмапливать его, а просто перенести в адресном пространстве в другую область памяти, как раз на рабочие виртуальные адреса кода и данных.

Интересно, пробуем — и — получаем ошибку MAP_FAILED (EINVAL). Почему?

Оказалось, если проследить системный вызов mremap в исходном коде ядра Linux, то окажется, что Linux до сих пор не поддерживает перемещение адресных блоков на больших страницах (https://github.com/torvalds/linux/blob/master/mm/mremap.c):

if (is_vm_hugetlb_page(vma))
  return ERR_PTR(-EINVAL);

Багфикс — откатываемся на shared маппинг. Грусть-тоска меня съедает…

И все же я хочу сделать решение лучше! Как? Вероятно, исходный постулат в виде «статическая линковка делает жизнь лучше» в данном конкретном случае не работает от слова совсем, скорее наоборот. Хорошо, создаем свою DSO!

Можно сбросить оковы, вздохнуть полной грудью и расправить крылья — теперь можно делать несколько системных вызовов и корректно обрабатывать коды возврата, а ещё можно отказаться от hugetlbfs. Если вы заметили, в первом подходе нужно было за один системный вызов подменить маппинг под кодом, который выполняется, иначе — SIGSEGV. И чтобы этого достичь с использованием mmap нужно передать и новые адреса, и указать на уже подготовленный блок памяти на больших страницах, т.е нужен файловый дескриптор на hugetlbfs (ну или сделать себе новый syscall, перекомпилить ядро, разлить по продакшн машинам, пройти security review… в общем, вы поняли).

Алгоритм меняем на следующий:


  1. Делаем анонимный 4K маппинг ровно с одной целью — заставить ядро найти в адресном пространстве процесса место для текущих рабочих маппингов кода и данных.
  2. Переносим (mremap) текущие рабочие маппинги в только что выделенное адресное пространство (случается перекрытие адресов => только что выделенный маппинг теряется без единого page fault). Так как код, который это делает, находится в DSO и имеет свой выделенный маппинг (который мы не трогаем), то SIGSEGV не случается.
  3. Выделяем на старых рабочих виртуальных адресах новый маппинг на больших страницах (mmap private + fixed + huge2m), по факту, это место сейчас вакантно.
  4. Если на этом этапе заканчивается память — возвращаем старые маппинги (которые мы утащили в пункте 2 в сторону) на старое место, если все хорошо — продолжаем.
  5. Копируем все бинарные данные из старых маппингов в новые.
  6. Отмапливаем старые маппинги — возвращаем 4К память системе.

Как видно, DSO явно меняет мир к лучшему. Какие же подводные камни ожидают нас в этом светлом и радужном месте?


  • Нужно заранее заполнить GOT/PLT таблицы, иначе вернется старый, добрый и всеми крайне любимый SIGSEGV. Дело в том, что по умолчанию динамический линковщик работает в ленивом режиме, а именно резолвит имена внешних DSO функций в адреса только по мере их использования. Эти таблицы хранятся в LOAD сегментах (помните историю с GNU_RELRO?). Наша DSO также использует спектр libc функций (mmap/mremap/memcpy), таблицы PLT/GOT этих функций лежат в LOAD сегменте нашего DSO, если резолвинг еще не выполнялся, запускается динамический линковщик. Если LOAD сегментов основного приложения нет на фиксированных виртуальных адресах, линковщик в ходе своей работы получает «access violation». Честно говоря, не копал, чего конкретно не хватило: heap живет отдельно (где хранится метаинформация линковщика), LOAD сегмент моего DSO живет также отдельно (не перемапливается), однако, как говориться, факт остается фактом. Данная проблема решается достаточно просто: добавляем флаг компиляции -Wl,-znow, что заставляет динамический линковщик быть более расторопным и сделать все необходимые расчеты на старте приложения (до входа в main).
  • Если в ходе работы fork память заканчивается, процесс получает SIGBUS. Да, мы сделали честный private маппинг, и fork его корректно копирует, дальше происходит copy-on-write, ядро пытается найти свободную большую страницу, и если этого сделать не получается, посылает SIGBUS. Что ж, это лучше, чем получить неопределенное поведение и повреждение данных, но все же хочется плавно переключиться на обычные страницы. Признаюсь, мы не писали обработчик для SIGBUS, чтобы в нем выполнить обратное перемапливание на 4К в потомке, скопировав данные родителя. На этом месте запал иссяк, и я просто сдвинул код перемапливания после вызова fork. Припомнился THP — у этой технологии есть положительные стороны, в частности, данный кейс, согласно беглому поиску, должен обрабатываться корректно, хотя как он реально обрабатывается на практике — ни разу не видел. Если знаете, расскажите.

Как известно, для каждой NUMA ноды большие страницы выделяются отдельно. Не верите?

© Habrahabr.ru