Как ускорить работу BERT на обычных процессорах: практическое руководство
BERT тормозит на процессоре? Узнайте, как правильно распределить нагрузку по ядрам, настроить привязку потоков и запустить параллельные экземпляры модели. Почти линейный рост пропускной способности без покупки GPU — практические инструкции с примерами кода.
BERT и похожие модели-трансформеры отлично решают задачи обработки текста, но их запуск на обычном процессоре часто превращается в испытание терпением. Запрос обрабатывается секундами, хотя сервер простаивает. Главная причина - не в слабости процессора, а в неправильной настройке. Эта статья покажет, как выжать максимум из вашего CPU: настроить многопоточность, привязать потоки к ядрам и запустить несколько копий модели параллельно. Результат - почти линейный рост пропускной способности без покупки дорогих видеокарт.
Сразу к сути. Простое увеличение числа ядер процессора не ускоряет BERT автоматически. Модель упирается в последовательные участки кода, промахи кэш-памяти и накладные расходы на синхронизацию потоков. Решение - комбинация трёх техник: правильный выбор числа потоков, их жёсткая привязка к физическим ядрам и горизонтальное масштабирование через пул независимых экземпляров модели. Такой подход превращает даже скромный серверный Xeon в конвейер для NLP-задач.
Почему BERT тормозит на CPU: главные ограничения
Трансформеры проектировались для графических процессоров. Их главная операция - перемножение матриц - на GPU выполняется тысячами ядер одновременно. Центральный процессор устроен иначе: у него меньше ядер, но каждое значительно мощнее и работает на более высокой частоте. Проблема в том, что BERT плохо использует эту мощь.
Механизм внимания (attention) требует перемножения трёх матриц - запросов, ключей и значений - для каждой пары токенов во входной последовательности. Сложность растёт квадратично относительно длины текста. Одно дело обработать твит длиной в 128 токенов, и совсем другое - страницу документа на 512 токенов. Процессор выполняет эти операции последовательно, блоками, которые помещаются в кэш.
Кэш-память становится узким горлышком. Когда модель не помещается в быстрый кэш процессора целиком, начинаются постоянные обращения в оперативную память. Каждый такой промах стоит десятков тактов. Ядра простаивают в ожидании данных. Добавление новых ядер лишь увеличивает число «голодных» вычислительных блоков, которые ждут доступа к шине памяти.
Накладные расходы на синхронизацию тоже вносят вклад. Когда один поток ждёт завершения операции в другом, полезная работа останавливается. В BERT есть участки, которые нельзя распараллелить - например, вычисление softmax или операции нормализации. По закону Амдала, общее ускорение ограничено долей последовательного кода. Если 10% операций выполняются строго друг за другом, максимальное ускорение на бесконечном числе ядер - в 10 раз. На практике предел достигается гораздо раньше.
Понимание этих ограничений помогает избежать главной ловушки: нельзя просто дать модели больше ядер и ждать чуда. Нужна настройка.
Многопоточность в PyTorch и ONNX Runtime: как задействовать все ядра
Современные библиотеки для инференса используют несколько уровней параллелизма. Внутри одной операции (например, перемножения матриц) работают вычислительные ядра библиотек Intel MKL или OpenBLAS. Это intra-op параллелизм. Одновременно разные операции в графе модели могут выполняться параллельно - это inter-op параллелизм. Правильная настройка обоих уровней критична для скорости.
Общее правило: суммарное число потоков не должно превышать число физических ядер процессора. Гипертрединг (логические ядра) даёт прирост в задачах с частыми простоями, но для вычислительно плотного инференса BERT он часто вредит. Два потока на одном физическом ядре конкурируют за кэш и исполнительные блоки, снижая общую производительность.
Настройка числа потоков в PyTorch
PyTorch использует переменные окружения и программные настройки. Базовый сценарий - установить число потоков равным количеству физических ядер:
import torch
torch.set_num_threads(8) # подставьте число физических ядер вашего CPU
print(f"Текущее число потоков: {torch.get_num_threads()}")
Переменные окружения MKL_NUM_THREADS и OMP_NUM_THREADS управляют потоками на уровне математических библиотек. Их значение должно совпадать с torch.set_num_threads(). Установите их до запуска скрипта:
export MKL_NUM_THREADS=8
export OMP_NUM_THREADS=8
Для inter-op параллелизма в PyTorch есть отдельный параметр. Он управляет числом потоков, которые одновременно выполняют разные операции графа. Для BERT его значение обычно оставляют небольшим - 1 или 2:
torch.set_num_interop_threads(2)
Экспериментируйте с этими цифрами на вашей конкретной модели и данных. Запустите бенчмарк на 1000 запросов, меняя число потоков от 1 до числа физических ядер. Запишите задержку и пропускную способность. Оптимум часто находится в точке «число физических ядер минус один» - одно ядро оставляют операционной системе для фоновых задач.
Настройка числа потоков в ONNX Runtime
ONNX Runtime часто работает быстрее PyTorch на CPU благодаря агрессивным оптимизациям графа. Настройка потоков выполняется через объект SessionOptions:
import onnxruntime as ort
sess_options = ort.SessionOptions()
sess_options.intra_op_num_threads = 8 # число потоков внутри операций
sess_options.inter_op_num_threads = 2 # параллелизм между операциями
sess_options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL # или ORT_PARALLEL
session = ort.InferenceSession("model.onnx", sess_options)
Параметр execution_mode управляет тем, как исполняется граф. ORT_SEQUENTIAL выполняет узлы друг за другом, используя все intra_op_num_threads для каждого узла. ORT_PARALLEL пытается запускать независимые узлы одновременно. Для BERT на CPU режим ORT_SEQUENTIAL даёт более предсказуемую производительность.
Перед запуском ONNX Runtime также установите переменные окружения OMP_NUM_THREADS и MKL_NUM_THREADS в то же значение, что и intra_op_num_threads. Дублирование настроек гарантирует, что все уровни библиотек работают согласованно.
Привязка потоков к ядрам: зачем это нужно и как сделать
Операционная система по умолчанию мигрирует потоки между ядрами. Это полезно для обычных приложений: если одно ядро перегружено, поток переносится на свободное. Для инференса BERT такая миграция вредна. Вместе с потоком «переезжает» его состояние, а главное - данные в кэш-памяти первого и второго уровней. После миграции поток начинает с холодного кэша, и первые операции выполняются в разы медленнее.
Привязка потоков (thread affinity, pinning) запрещает операционной системе переносить поток с назначенного ядра. Кэш остаётся «тёплым», задержки стабильны. Производительность перестаёт скакать от запроса к запросу.
Самый простой способ - переменные окружения, которые обрабатывает библиотека OpenMP:
export OMP_PROC_BIND=true
export OMP_PLACES=cores
OMP_PROC_BIND=true включает привязку. OMP_PLACES=cores указывает, что каждый поток закрепляется за целым физическим ядром (а не за логическим потоком гипертрединга).
Для более тонкого контроля в Linux используют утилиту taskset. Она запускает процесс на указанном наборе ядер:
taskset -c 0-7 python inference_server.py
Эта команда ограничивает процесс ядрами с 0 по 7. Потоки внутри процесса всё ещё могут мигрировать между этими восемью ядрами, но не уйдут на другие. В комбинации с OMP_PROC_BIND каждый поток жёстко закрепляется за одним ядром из этого набора.
В PyTorch и ONNX Runtime привязку можно задать и программно, но переменные окружения действуют глобально и гарантируют, что все нижележащие библиотеки (MKL, OpenBLAS, libgomp) подчинятся правилу. Начните с них, а потом переходите к API конкретных библиотек, если нужна более сложная топология.
Параллельные экземпляры модели: почти линейный рост пропускной способности
Один экземпляр BERT, даже идеально настроенный, использует ограниченное число ядер эффективно. Дальнейшее наращивание потоков упирается в последовательные участки кода и пропускную способность памяти. Что делать с оставшимися ядрами серверного процессора, где их 32 или 64? Запустить несколько независимых копий модели.
Идея проста. Вместо одного процесса с 32 потоками запускаем 8 процессов, каждый из которых использует 4 потока и привязан к своим физическим ядрам. Каждый процесс загружает свою копию BERT в память и обрабатывает запросы независимо. Общая пропускная способность растёт почти линейно, пока хватает пропускной способности памяти и места в кэше последнего уровня (L3).
Почему это работает лучше, чем один экземпляр на всех ядрах? Потоки внутри одного процесса синхронизируются через общую память. При 32 потоках накладные расходы на синхронизацию съедают заметную долю времени. Независимые процессы не синхронизируются вообще. Каждый работает в своём адресном пространстве со своим кэшем.
Предел масштабирования наступает, когда суммарный объём моделей перестаёт помещаться в L3-кэш или исчерпывается пропускная способность оперативной памяти. Для типичного BERT-base (110 млн параметров, около 440 МБ в FP32) на процессоре с 20 МБ L3-кэша это наступает быстро. Квантование в INT8 снижает размер модели в 4 раза и заметно отодвигает этот предел.
Реализация пула моделей на Python
Простейший пул воркеров делается с помощью модуля multiprocessing. Каждый воркер загружает модель при старте и ждёт задачи из общей очереди:
import torch
from transformers import BertTokenizer, BertModel
import multiprocessing as mp
def worker(worker_id, core_ids, input_queue, output_queue):
# Привязываем процесс к конкретным ядрам
import os
os.sched_setaffinity(0, core_ids)
# Настраиваем число потоков под выделенные ядра
torch.set_num_threads(len(core_ids))
# Загружаем модель (каждый воркер - своя копия)
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
model = BertModel.from_pretrained('bert-base-uncased')
model.eval()
while True:
task = input_queue.get()
if task is None: # сигнал завершения
break
text = task
inputs = tokenizer(text, return_tensors='pt', padding=True, truncation=True)
with torch.no_grad():
outputs = model(**inputs)
output_queue.put(outputs.last_hidden_state)
if __name__ == '__main__':
num_workers = 4
cores_per_worker = 4
input_queue = mp.Queue()
output_queue = mp.Queue()
processes = []
for i in range(num_workers):
core_ids = list(range(i * cores_per_worker, (i + 1) * cores_per_worker))
p = mp.Process(target=worker, args=(i, core_ids, input_queue, output_queue))
p.start()
processes.append(p)
# Отправляем задачи
for text in ["Пример текста 1", "Пример текста 2"]:
input_queue.put(text)
# Сигналим о завершении
for _ in processes:
input_queue.put(None)
for p in processes:
p.join()
Этот код создаёт 4 воркера, каждый привязан к своим 4 ядрам. Задачи распределяются через общую очередь. В реальном проекте оберните это в HTTP-сервер на FastAPI или gRPC, где каждый запрос попадает в очередь и обрабатывается первым свободным воркером.
Важный нюанс: загрузка модели происходит в каждом процессе отдельно. Это увеличивает потребление памяти, но изолирует состояние. Если памяти не хватает, используйте torch.multiprocessing с shared memory для тензоров модели, но это усложняет код и требует аккуратной синхронизации при загрузке.
Типичные ошибки при оптимизации BERT на CPU
Годы внедрения NLP-моделей в продакшен выявили набор повторяющихся ошибок. Проверьте, нет ли их у вас.
Слишком много потоков. Установка числа потоков больше числа физических ядер - самая частая проблема. Гипертрединг создаёт иллюзию дополнительных ядер, но два потока на одном ядре делят кэш и исполнительные блоки. Производительность падает на 10-30% по сравнению с настройкой «один поток на ядро».
Игнорирование привязки потоков. Без OMP_PROC_BIND потоки мигрируют, кэш сбрасывается, задержка нестабильна. Один и тот же запрос может выполняться то 50 мс, то 150 мс. Для продакшена с SLA такая дисперсия неприемлема.
Один экземпляр на все ядра. Запуск единственной копии BERT на 32-ядерном процессоре с 32 потоками. Закон Амдала ограничивает ускорение, и половина ядер простаивает в ожидании синхронизации. Правильный подход: 4-8 экземпляров по 4-8 потоков каждый.
Неправильный бэкенд для инференса. Стандартный PyTorch в режиме eager mode выполняет операции по одной, не оптимизируя граф. ONNX Runtime или TorchScript компилируют модель в статический граф и применяют слияние операций, уменьшая число обращений к памяти. Разница в скорости на CPU достигает 2 раз. Мы разбирали этот эффект детально в статье о 100-кратном ускорении вывода Hugging Face.
Отсутствие квантования. FP32-модель занимает 440 МБ, INT8 - 110 МБ. Меньше данных - быстрее загрузка в кэш, меньше промахов. PyTorch и ONNX Runtime поддерживают динамическое квантование «на лету», которое снижает точность вычислений до 8 бит без переобучения модели. Потери качества обычно в пределах 1%.
Когда CPU-оптимизаций достаточно, а когда пора переходить на GPU
Оптимизированный CPU справляется с умеренными нагрузками. Ориентируйтесь на конкретные метрики вашего продакшена.
Для сценариев с низкими требованиями к задержке (до 10 запросов в секунду, допустимая задержка 200-500 мс) процессора хватает с запасом. Чат-бот техподдержки, внутренний поиск по документам, разбор обратной связи - эти задачи не требуют мгновенного ответа. Настроенный 16-ядерный сервер с пулом из 4 экземпляров BERT-base обрабатывает 20-30 текстов в секунду с задержкой около 100 мс.
Пакетная обработка меняет картину. GPU эффективно обрабатывает сразу десятки примеров, перемножая матрицы для всего батча одной операцией. На CPU батчинг тоже работает, но прирост скромнее. Если нужно классифицировать миллион отзывов за ночь - GPU окупается быстро.
Длина входных последовательностей критична. При 128 токенах CPU и GPU сопоставимы по соотношению цена/производительность. При 512 токенах квадратичная сложность attention «взрывает» время обработки на процессоре, и видеокарта уходит в отрыв.
Размер модели тоже важен. BERT-base на CPU после квантования работает приемлемо. BERT-large (340 млн параметров) уже требует значительных ресурсов. Модели поколения T5 или LLaMA на процессорах запускать можно, но скорость будет низкой даже после всех оптимизаций.
Практический критерий: если после настройки многопоточности, привязки потоков и запуска пула экземпляров задержка или пропускная способность не достигают целевых показателей - переходите на GPU. Аренда облачной машины с T4 или A10 на несколько часов для теста даст точный ответ быстрее, чем неделя теоретических расчётов. Методика настройки гиперпараметров, описанная в руководстве по Ray Tune, применима и к подбору конфигурации инференса - автоматизируйте поиск оптимальных параметров.
Если вы экспериментируете с энкодер-декодер архитектурами, собранными из BERT и GPT, обратите внимание на метод warm-starting. Он экономит вычислительные ресурсы на этапе обучения, что косвенно снижает требования к инфраструктуре и для инференса.
Оптимизация BERT на CPU - это дисциплина. Измеряйте каждый параметр, не доверяйте интуиции и проверяйте каждую гипотезу бенчмарком на реальных данных. Правильно настроенный процессор способен удивить.