Владельцы двухсокетных серверов (а таких среди энтузиастов и админов немало) давно знают: поставить современную 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.
Тесты на железе: когда 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 до конца года. Потому что идея слишком очевидна, чтобы её игнорировать.