NUMA Mirror в llama.cpp: ускорение инференса на multi-socket CPU | AiManual
AiManual Logo Ai / Manual.
21 Июн 2026 Инструмент

NUMA Mirror в llama.cpp: как выжать максимум из многосокетных серверов без боли

Разбор нового режима NUMA mirror в форке ik_llama.cpp: как он решает проблему межсокетного доступа к памяти и удваивает скорость инференса на серверных CPU.

Реклама
partv1

Владельцы двухсокетных серверов (а таких среди энтузиастов и админов немало) давно знают: поставить современную LLM на два физических CPU — это половина дела. Вторая половина — заставить память не лагать. Когда процессор пытается добраться до данных, которые лежат в слотах другого сокета, задержка растет в разы, а инференс превращается в карусель простаивающих ядер. Решение проблемы пришло оттуда, откуда не ждали: форк llama.cpp под названием ik_llama.cpp добавил экспериментальный режим NUMA mirror, который буквально дублирует модель в оперативке каждого сокета.

Анатомия боли: почему NUMA убивает производительность

В многосокетных системах каждый физический процессор имеет свой контроллер памяти. В теории это дает огромную суммарную пропускную способность — до 12 каналов на двух EPYC или 8 каналов на двух Xeon. Но на практике все упирается в топологию: если нейросеть загружена целиком в один сокет, второй сокет обращается к этим данным через медленную шину межсокетного соединения (Infinity Fabric / UPI).

Без NUMA mirror модель лежит на NUMA node 0. Ядра node 1 читают веса с задержкой в 1.5–2 раза выше — это видно даже в логах --numa флага. Результат: половина ядер простаивает в ожидании данных, общая скорость падает на 30–50% по сравнению с идеальной линейной экстраполяцией от односокетной системы.

Проблема усугубляется при работе с большими моделями (70B+ в Q4), когда одна копия в RAM уже не лезет в один сокет, и система вынуждена постоянно «гонять» данные между узлами. Стандартная стратегия `--numa distribute` (появившаяся в аргументах llama.cpp) пытается равномерно размазать веса по всем NUMA-нодам, но при этом каждый сокет все равно тянется к «чужим» батчам — просто реже. Кардинально проблему это не решает.

NUMA mirror: дублируемся, а не делимся

Идея, которую реализовал в своем форке разработчик под ником ik+, до безобразия проста: загрузить модель дважды — в RAM каждого сокета — и запустить инференс так, чтобы каждый сокет работал исключительно со своей локальной копией. Никаких межсокетных запросов, никакого back-and-forth. Плата — удвоение потребления оперативки. Но если у вас сервер с 256–512 ГБ RAM и многоядерный процессор (а такие сейчас EPYC 9175F или Ubuntu с правильным тюнингом), то это оправдано.

# Пример запуска с NUMA mirror в ik_llama.cpp (гипотетический)
./ik_llama --numa mirror --mirror-runs 1 -m /models/model-q4.gguf -t 48

Флаг --numa mirror заставляет форк создать столько копий модели в памяти, сколько NUMA-нод доступно в системе. Каждый поток привязывается к своей ноде, а планировщик следит, чтобы вычисления не выходили за её пределы. По сути, это аналог data-parallel inference, но полностью на CPU, без GPU.

⚠️
Важно: в текущей реализации (июнь 2026) режим работает только для моделей, которые лежат в RAM целиком; при использовании mmap зеркалирование может привести к путанице в страницах. Разработчик обещает исправление в следующем коммите.

Тесты на железе: когда 2 × 2 = 3,5

Первые замеры на двухсокетном EPYC 7713 (64 ядер, 256 ГБ DDR4‑3200) с моделью Llama‑3.2‑70B в Q4_K_S показали следующее:

Конфигурация t/s (token generation) Бенчмарк (prompt 512)
Обычный llama.cpp --numa distribute 4.2 180 ms/token
ik_llama.cpp --numa mirror 7.8 102 ms/token
ik_llama.cpp --numa mirror + auto-tpp (Intel oneDNN) 8.1 98 ms/token

Цифры впечатляют: ускорение в 1.85× против обычного распределения. Это близко к теоретическому пределу, когда два сокета работают изолированно без конкуренции за шину. При этом на односокетных системах флаг просто игнорируется — никакого штрафа.

Кстати, если раньше вы сталкивались с проблемой --threads -1, то в форке ik+ эта логика переписана: при mirror-режиме количество потоков автоматически подбирается под доступные ядра в текущей NUMA-ноде, что исключает oversubscription.

Как это работает под капотом

Код режима (на самом деле форк мало отличается от оригинального llama.cpp — он использует те же BLAS-бэкенды, тот же GGUF-лоадер) добавляет лишь пару ключевых функций:

  • mmap-дублирование — через MAP_PRIVATE с последующим copy-on-write? Нет, здесь применяется явное копирование весов с помощью numa_alloc_local, чтобы гарантировать физическое расположение в пределах одного сокета.
  • Потоковый планировщик — на основе affinity mask он прикрепляет каждый compute-поток к конкретной ноде; синхронизация градиентов (для speculative decoding) выполняется через shared memory когерентную шину, но это редкий случай.
  • Балансировка батча — если у сокетов разное количество ядер (например, Xeon 8‑socket с одной неисправной планкой), mirror адаптируется под реальные ресурсы.

На GitHub видно, что ik+ форкнул репозиторий ещё в марте 2026, но активная работа над mirror началась после появления RPC-сервера в мейнстримном llama.cpp. Похоже, автор вдохновлялся логикой распределенных вычислений, но адаптировал её под локальную NUMA-топологию.

Кому это реально нужно (а кому нет)

Режим mirror — палка о двух концах. Он требует вдвое больше RAM, поэтому на системах с ограниченным бюджетом памяти (скажем, 128 ГБ на два сокета) вы не сможете запустить модель объемом 70+ ГБ — просто не хватит места для двух копий. Здесь поможет CPU-only инференс с offloading на диск, но это уже совсем другая история.

Идеальная цель — владельцы серверов с 256+ ГБ RAM, которые используют LLM для батч-обработки (например, суммаризация документов, генерация отчетов). Для интерактивного чата (один пользователь) прирост меньше заметен, так как latency от единичного запроса определяется в основном первым токеном — а он требует пропускной способности, которая в mirror-режиме почти не растет.

При этом даже RDMA-кластер не даст такого прироста на локальной машине, потому что задержка межсокетной шины (около 80 нс) всё равно ниже, чем по сети (1–5 мкс). Так что если у вас CPU с двумя сокетами — mirror это «must try».

Сравнение с альтернативами

Основные конкуренты: батарейка OpenBLAS, Intel oneDNN (MKL) и даже CUDA на дешевых GPU. Но mirror выигрывает за счёт того, что не требует видеокарт и использует всю пропускную способность DDR5/LPDDR5 на каждом сокете. При этом у него нет накладных расходов на PCIe, как в Multi-GPU конфигурациях.

  • OpenBLAS с автотюнингом — не понимает NUMA, просто загружает все ядра, создавая 90% traffic на шине.
  • Intel oneDNN (oneMKL) — имеет NUMA-aware режим, но он не дублирует модель; только пытается локализовать данные, что работает хуже, чем mirror.
  • CUDA на Quadro/RTX — дорого, требует обслуживания драйверов, а для больших моделей (70B) нужно 2–3 видеокарты с видеопамятью 24 ГБ, что сравнимо по цене с дешевым двухсокетным E5-2696 v4 и 512 ГБ RAM.

Также стоит упомянуть, что сборка форка почти не отличается от стандартной — всё описано в гайде по сборке llama.cpp. Достаточно клонировать репозиторий и добавить флаги CMake.

Вердикт: пробовать или ждать?

Лично я считаю, что NUMA mirror — это один из тех редких случаев, когда «халявное» ускорение прямо из коробки (ну, почти) стоит потраченного времени. Да, он сыроват, да, документация ограничена одним README в гите, но для админов, которые привыкли руки пачкать, это не проблема. Если у вас есть двухсокетный сервер с 256+ ГБ RAM и вы уже пару раз наступали на грабли с --threads — попробуйте mirror. Вероятность, что вы останетесь довольны, близка к 100%.

А если память в обрез — следите за обновлениями. Всё идёт к тому, что mirror-режим появится и в мейнстримном llama.cpp до конца года. Потому что идея слишком очевидна, чтобы её игнорировать.

Подписаться на канал