Педальку в пол, или как ещё ускорить 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
Здесь на ум приходит старый добрый Морфеус, и его синяя и красная таблетки:
- Синяя таблетка (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):
Конечно же, это был «усиленный» 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. Ох…
Засучив повыше рукава и набрав побольше воздуха в легкие, я начал медленное и основательное погружение в 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
и делаем аналогично:
- Загрузка текущих LOAD сегментов используя
dl_iterate_phdr
(спасибо тебе,glibc
, за точность, пунктуальность и беспристрастность в представлении данных: никакой дополнительной магии сGNU_RELRO
) - Проверка: сегменты не пересекаются с поправкой на выравнивание в 2М
- Дополнительно выравниваем сегменты в случае, если включен ASLR (в этом случае, сегменты имеют сдвиг 0×555555554000 и дополнительный рандомный сдвиг, который генерируется ядром каждый раз при запуске приложения — и эти сдвиги, разумеется, выравниваются ядром по границе 4К)
- Выделяем большие странички используя специальную файловую систему
hugetlbfs
(для каждого нашего маппинга кода и данных — свою копию, доступ MAP_PRIVATE) - Копируем туда все наши маппинги по очереди: открыли файл на
hugetlbfs
, сделалиmmap
, получили виртуальный базовый адрес от ядра, скопировали, сделалиmunmap
. - Оставляем файловые дескрипторы открытыми для последующих операций.
- Проверочка — данные действительно скопировались? Мапим первый же файловый дескриптор от
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 (нехватки больших страниц):
- открываем файловый дескриптор на
hugetlbfs
; - выделяем «большую» память с помощью
mmap
(передаем файловый дескриптор); - здесь проверяем успешен ли
mmap
, если нет, сообщаем об ошибке, возвращаем все на место; - копируем наш рабочий сегмент в выделенную память;
- отмапливаем «большую» память, оставляем открытым файловый дескриптор;
- делаем финальный
mmap
(fixed|shared) поверх существующего рабочего маппинга; - этот системный вызов никогда не падает по нехватке памяти, так как маппинг shared (нет необходимости делать резервирование страниц в ядре), а вся «большая» память уже выделена на шаге 2 и проверена на корректность на шаге 3.
Чем чревато MAP_SHARED
под маппингом кода/данных?
- Перестаёт работать
fork
. Нет, он работает корректно, но не копирует shared маппинги между потомком и родителем (что логично), это в свою очередь приводит к хаотичным непредсказуемым ошибкам в связи с гонками при доступе к одной и той же памяти из потомка и родителя. - Перестают работать новые версии
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… в общем, вы поняли).
Алгоритм меняем на следующий:
- Делаем анонимный 4K маппинг ровно с одной целью — заставить ядро найти в адресном пространстве процесса место для текущих рабочих маппингов кода и данных.
- Переносим (
mremap
) текущие рабочие маппинги в только что выделенное адресное пространство (случается перекрытие адресов => только что выделенный маппинг теряется без единого page fault). Так как код, который это делает, находится в DSO и имеет свой выделенный маппинг (который мы не трогаем), тоSIGSEGV
не случается. - Выделяем на старых рабочих виртуальных адресах новый маппинг на больших страницах (
mmap
private + fixed + huge2m), по факту, это место сейчас вакантно. - Если на этом этапе заканчивается память — возвращаем старые маппинги (которые мы утащили в пункте 2 в сторону) на старое место, если все хорошо — продолжаем.
- Копируем все бинарные данные из старых маппингов в новые.
- Отмапливаем старые маппинги — возвращаем 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