Когда приложение на DPDK падает на старте со строкой EAL: Cannot allocate memory, а под нагрузкой начинает терять пакеты, привычные инструменты мало что показывают: top видит гигабайты RSS, но внутри них не различает ни зон, ни куч, ни кэшей ядер. Выделение памяти в DPDK устроено слоями, и у каждого слоя свой измеритель: резерв hugepages на стороне ядра, карта memzone и кучи rte_malloc внутри EAL, пулы mempool на самом верху. Сбой внизу наверху выглядит загадочным отказом, поэтому профилирование ведут по всем уровням, сравнивая снимки до и под нагрузкой. Разобраны оба режима работы памяти, параметры запуска, устройство пулов с числовым расчетом, дампы procinfo и testpmd, наблюдение через sysfs и proc, prefetch и NUMA в горячем пути. После такого разбора зависание на старте, лишние файлы rtemap и просадка на get из пула перестают быть черным ящиком.
Карта памяти DPDK и чем она отличается от обычного malloc
Основание карты лежит в hugetlbfs: при старте rte_eal_init отображает огромные страницы через mmap и собирает из них таблицу сегментов rte_memseg, один сегмент на физическую страницу. Над сегментами работает аллокатор memzone, резервирующий именованные непрерывные области под нужды драйверов и библиотек; выравнивание по умолчанию равно строке кэша, то есть 64 байтам, а любое другое значение обязано быть степенью двойки. Поиск зоны по имени возвращает дескриптор с физическим адресом, что критично для DMA.
Следующий уровень составляют кучи rte_malloc, по одной на каждый узел NUMA. Свободное место внутри держится в списке с заголовками в состояниях FREE, BUSY и PAD: соседние свободные блоки сливаются при возврате, хвост больше 128 байт отрезается в отдельный элемент, а меньший хвост уходит в паддинг. Каждый вызов берет блокировку, поэтому в горячий путь трафика rte_malloc не годится, его место в коде конфигурации. Объекты фиксированного размера под пакеты живут выше, в пулах mempool, а создают все эти структуры на главном lcore, потому что функции их инициализации не потокобезопасны.
Для профилирования важен режим работы памяти: в динамическом страницы приходят и уходят по мере запросов, в legacy весь объем резервируется на старте и застывает, и его карту снимают один раз после rte_eal_init.
Резерв hugepages на 2 МБ и 1 ГБ и его проверка до запуска
DPDK требует заранее подготовленные огромные страницы, иначе приложение даже не стартует. Размер выбирают под карту: страницы по 2 МБ гибки, страницы по 1 ГБ снижают давление на TLB и число файловых дескрипторов, но в работающей системе могут не собраться из-за фрагментации физической памяти, поэтому большой размер обычно задают при загрузке ядра.
echo 4096 > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages
echo 16 > /sys/kernel/mm/hugepages/hugepages-1048576kB/nr_hugepages
grep -i huge /proc/meminfo
Первая строка резервирует 4096 страниц по 2 МБ, итого 8 ГБ, вторая добавляет 16 страниц по 1 ГБ. В загрузочной конфигурации GRUB для гигабайтных страниц пишут default_hugepagesz=1G hugepagesz=1G hugepages=16, а раскладку резерва по узлам NUMA смотрят в /sys/devices/system/node/node0/hugepages и в node1.
Вывод grep дает опорную линию всего сеанса:
HugePages_Total: 4096
HugePages_Free: 4096
HugePages_Rsvd: 0
HugePages_Surp: 0
Hugepagesize: 2048 kB
Hugetlb: 8388608 kB
Total это весь резерв, Free свободные страницы прямо сейчас, Rsvd бронь под будущие выделения, Surp сверхплановые, Hugetlb суммарный объем в килобайтах, здесь 8 ГБ. Под нагрузкой Free падает, после корректного завершения возвращается к исходному числу; если не вернулось, где-то осталась неснятая карта, и это первый симптом утечки. Работа с файлами в hugetlbfs обычно требует прав суперпользователя, а в анонимных режимах без файлов скажется лимит MEMLOCK, видимый в ulimit -l.
Параметры EAL socket-mem, legacy-mem и файлы сегментов
Точка входа профилирования это командная строка приложения, где EAL получает всю раскладку памяти:
./build/app/dpdk-testpmd -l 2-5 -n 4 --socket-mem=1024,1024 --file-prefix=prof1 -- -i
Значение socket-mem задается в мегабайтах на узел NUMA, здесь по 1024 МБ, то есть по 1 ГБ на узел; новое имя параметра numa-mem, -m раздает одинаковый объем всем узлам. В динамическом режиме предвыделенное закрепляется навсегда и системе не возвращается, зато сверх него приложение берет новые страницы и отдает их обратно по мере работы. Параметр legacy-mem включает старый режим: весь объем резервируется на старте, сортируется в крупные IOVA-непрерывные куски и больше не меняется. Его выбирают, когда драйверу нужны физически непрерывные блоки больше одной страницы; в динамическом режиме непрерывность не гарантирована, и для больших DMA-блоков аллокатору явно передают флаг RTE_MEMZONE_IOVA_CONTIG в вызове rte_memzone_reserve, иначе запрос честно завершится отказом.
Файлы сегментов тоже часть карты: по умолчанию каждая страница получает собственный файл rtemap_N в точке монтирования hugetlbfs, путь переопределяет --huge-dir, а префикс --file-prefix разводит по одной точке монтирования несколько первичных процессов. Служебные данные лежат в /var/run/dpdk/prof1: там config, fbarray-карты сегментов и сокет для вторичных процессов. Опция --single-file-segments складывает страницы в один файл на список сегментов, экономит дескрипторы и нужна vhost-user, но с legacy-mem не сочетается; --in-memory вовсе отказывается от файлов и мультипроцессности. Для ускорения рестарта существует --huge-unlink=never: чистка страниц занимает более 95% времени их отображения, поэтому повторный запуск оставляет содержимое как есть, сегменты получают флаг RTE_MEMSEG_FLAG_DIRTY и очищаются только при запросе через rte_zmalloc. Рост карты можно контролировать и изнутри: события дают rte_mem_event_callback_register, потолок ставит валидатор с опцией --numa-limit.
Устройство mempool, кольца и кэши ядер с расчетом размера пула
Пул mempool это аллокатор объектов фиксированного размера, под трафик он главный: пакеты живут в буферах mbuf, которые выдает пул. Свободные объекты по умолчанию лежат в кольце rte_ring, и каждый get или put упирается в сравнение с обменом, CAS, конкуренция ядер за которое съедает такты. Противоядие это кэш ядра: каждое ядро держит собственный стек указателей без блокировок, размер задается при создании пула, потолок константа RTE_MEMPOOL_CACHE_MAX_SIZE, по умолчанию 512. Порог сброса равен полуторному размеру кэша: при cache_size 250 лишнее уходит в общее кольцо после 375 объектов.
Типовое создание пула выглядит так:
rte_pktmbuf_pool_create("mbuf_pool", 65536, 250, 0,
RTE_MBUF_DEFAULT_BUF_SIZE, SOCKET_ID_ANY);
Расчет здесь стоит проговорить. Элемент состоит из заголовка rte_mbuf на 128 байт и буфера на 2176 байт, где 2048 данные и 128 headroom, итого 2304 байта, кратных строке кэша. Умножаем: 65536 на 2304 даст 150994944 байта, ровно 144 МБ под кольцо. Кэши добавляют до 250 объектов на каждое ядро, 32 ядра дадут еще до 8000 объектов, то есть 18432000 байт, порядка 18 МБ. Итоговая карта пула около 162 МБ, и если sysfs показывает минус восемьдесят страниц по 2 МБ, это он, а не утечка в драйвере. Обработчики пула сменяемы через rte_mempool_set_ops_byname и загрузку -d, в мультипроцессных схемах порядок -d обязан совпадать во всех процессах.
Наблюдение за картой памяти со стороны операционной системы
Со стороны ядра видно то, чего не покажут внутренние дампы: сколько страниц занято, как они легли по узлам и растет ли карта на бегу. Сеанс профилирования удобно вести по одной схеме:
- снять опорную линию до запуска командой grep -i huge /proc/meminfo;
- запустить приложение, зафиксировать его PID и привязку ядер;
- подключиться вторичным процессом procinfo и снять дампы memzone и mempool;
- повторить снимки под нагрузкой и сравнить Free, счетчики пула и число rtemap;
- после корректного завершения убедиться, что Free вернулось к исходному числу.
В режиме пофайловых страниц каждый занятый огромный лист соответствует одному файлу rtemap_N, поэтому счетчик файлов равен числу страниц в карте:
ls /dev/hugepages | grep -c rtemap
numastat -p $(pidof dpdk-testpmd)
grep VmLck /proc/$(pidof dpdk-testpmd)/status
Первая строка дает число страниц, numastat показывает раскладку памяти по узлам NUMA, перекос лечится настройкой socket-mem и привязкой ядер параметром -l. Поле VmLck отражает зафиксированные байты и растет вместе с картой, а в динамическом режиме рост виден прямо в системных вызовах:
strace -f -e trace=mmap,munmap -p $(pidof dpdk-testpmd) 2>&1 | grep -i hugetlb
Каждая новая страница добавляет mmap файла из hugetlbfs, возврат выглядит как munmap, и по ленте вызовов видно момент, когда приложение начало раздуваться. Если после аварийного завершения файлы rtemap остались в /dev/hugepages, а free_hugepages в sysfs меньше ожидаемого, страницы держит мусор от карты; чистится он удалением файлов после гарантированного завершения процессов.
Встроенные дампы DPDK для memzone, mempool и куч malloc
Внутренности EAL доступны без остановки трафика: утилита procinfo из примеров DPDK подключается к работающему первичному процессу вторичным и печатает таблицы:
dpdk-procinfo -l 0 --file-prefix=prof1 -- --memzone | head -30
dpdk-procinfo -l 0 --file-prefix=prof1 -- --mempool=mbuf_pool
dpdk-procinfo -l 0 --file-prefix=prof1 -- --iter-mempool=mbuf_pool | head -10
Первая команда перечисляет зоны memzone с адресами, длинами и узлами NUMA; по ней читается фрагментация: среди крупных зон много мелких, и под новый непрерывный кусок места может не найтись. Вторая печатает состояние пула: число объектов, сколько лежит в общем кольце и в кэшах ядер, а при включенной статистике добавляются счетчики удачных и неудачных get и put по каждому ядру. Третья перебирает сами элементы пула, что помогает поймать застрявшие mbuf, которые приложение забыло вернуть.
Те же таблицы в коде приложения печатают rte_memzone_dump, rte_mempool_list_dump и rte_malloc_dump_stats с числом аллокаций, занятыми байтами, свободным объемом и наибольшим свободным блоком; rte_malloc_dump_heaps печатает все кучи, в консоли testpmd работают дампы dump_memzone и dump_mempool. Для глубокого анализа пул собирают с отладкой: RTE_LIBRTE_MEMPOOL_DEBUG в config/rte_config.h добавляет защитные поля в начале и конце объекта и ловит переполнение буфера, а более легкий RTE_MEMPOOL_STATS дает статистику get и put по ядрам. Оба режима повышают накладные расходы, их включают на время диагностики и выключают на бенчмарках.
Prefetch, NUMA и счетчики TLB в горячем пути
Когда пул с кэшем уже стоит, сам get из кэша обходится в пару тактов, а дорогой становится первая точка касания объекта: заголовок mbuf и его данные лежат в разных строках кэша, и первый доступ к объекту тянет промах из памяти. Софтверный конвейер скрывает эту задержку: обрабатывая пакет с номером i, приложение заранее подтягивает rte_mbuf_prefetch_part1(rx_pkts[i + 2]) для пакета через два, через несколько итераций добавляет rte_mbuf_prefetch_part2, а данные первого сегмента прогревает rte_prefetch0. В примере l3fwd собран именно такой конвейер, его образец стоит копировать.
Оценить эффект помогают счетчики производительности:
perf stat -e dTLB-load-misses,dTLB-store-misses -p $(pidof dpdk-testpmd) sleep 10
perf record -F 99 -g -p $(pidof dpdk-testpmd) -- sleep 10
Падение dTLB промахов после включения prefetch говорит, что конвейер попал в цель; большие страницы на 1 ГБ дополнительно снимают давление на TLB по сравнению с 2 МБ, поэтому на картах в десятки гигабайт выигрыш заметен. NUMA в горячем пути видна через numastat и через правила аллокатора: rte_malloc отдает память узла, где исполняется вызывающее ядро, а пул создается с явным socket_id, и буферы для ядер узла 1, созданные на узле 0, превращаются в поездки через межпроцессорную шину. На x86 пул может паддить объекты так, чтобы старты легли на разные каналы и ранги DIMM, число каналов и рангов передают параметры -n и -r; помогает это, когда в горячем пути читаются первые 64 байта буфера, а на ARM контроллер размазывает адреса хешем, и паддинг не нужен.
Финальный порядок инструментов получается компактным: sysfs и meminfo отвечают за резерв и утечки, rtemap, numastat и VmLck за карту процесса, procinfo и дампы за зоны, кучи и состояние пулов, кэши ядер и счетчики TLB за горячий путь. Сравнение снимков на холостом ходу и под нагрузкой почти всегда называет слой, где рождается проблема, а расчет пула до запуска предупреждает о половине сюрпризов: 65536 mbuf это 144 МБ плюс 18 МБ кэшей, и по счетчику Free заранее видно, сколько страниц уйдет на трафик.