Когда в системе есть узел между LLM и пользователем его скорость также важна, как и скорость самой LLM. Когда этот узел сам является моделью (и иногда даже — тоже LLM) — задача ускорения становится совсем веселой и её нужно уметь решать разными способами. В этой работе я опишу процесс ускорения guardrail‑модели — узла, которые проверяет — нет ли на входе или выходе опасного контента.
Guard стоит на входе/выходе LLM‑приложения. В статье речь про небольшую модель — чуть больше 0.5 Gb. Но она вызывается дважды за один ход диалога, и сначала пользователь ждет, пока guard проверит его запрос перед отправкой в LLM, затем — пока он проверит сгенерированный ответ перед выдачей пользователю.
Моей задачей было ускорить такую небольшую guard‑модель (Locustfile, базовые benchmark‑конфиги и инструкции по воспроизведению экспериментов в репо). Я потестила пять инструментов: TensorRT, NVIDIA Triton, vLLM, Ray Serve и отдельно — переход бэкбона на Flash DeBERTa.
Про допущенные ошибки, результаты и выводы — о том, как бы я построила подобную работу сейчас — ниже. Надеюсь, это сбережет вам время.
Что такое PII‑энкодер и почему такую модель тяжело ускорять
Прежде чем переходить к бенчмаркам, небольшое введение в тему.
Задача модели — найти в тексте сущности: имена, адреса, телефоны, почты, номера документов, и вынести вердикт по тексту целиком, безопасен он или нет. Первая часть — это NER, распознавание именованных сущностей. Вторая — обычная классификация.
Классический NER‑классификатор устроен так: энкодер, поверх него голова на фиксированное число классов, разметка по токенам. Но для guard‑модели это не подходит, так как набор того, что считается чувствительными данными, меняется от клиента к клиенту и от регламента к регламенту, переобучать модель на каждое такое требование невозможно.
Поэтому современные модели этого класса строятся zero‑shot: типы сущностей подаются в модель текстом, в том же самом входе, что и анализируемый документ. Схематично вход выглядит примерно так:
[ENT] person [ENT] address [ENT] email [ENT] phone [SEP] Send $500 to John Smith at john.smith@gmail.com
Энкодер прогоняет всю эту склейку целиком и на выходе получает в одном пространстве представлений эмбеддинги меток и эмбеддинги текста. Далее модель перебирает спаны (подстроки ограниченной длины), после строит представление каждого спана из скрытых состояний его крайних токенов и скорит спан против каждой метки. Скор выше порога — сущность найдена.
Классификация инжектится туда же и тем же способом: метки задачи становятся еще одним куском входа. Поэтому одна модель отдает и список найденных персданных, и итоговый вердикт за один forward pass.
Такой подход делает модель гибкой, набор сущностей можно менять прямо в запросе, не переобучая модель. Но за эту гибкость приходится платить. Для инференса она создает семь инженерных ограничений:
1. Форма входа меняется от запроса к запросу. Длина последовательности зависит от схемы, которая также передается внутри запроса. Для инструментов компиляции это неудобный сценарий. TensorRT эффективнее работает, когда форма входа известна заранее и под нее можно подобрать оптимальные ядра. При динамических размерах приходится задавать профили min/opt/max, дополнять входы паддингом или собирать отдельные engine под разные формы. CUDA Graphs накладывают еще более жесткое ограничение — граф захватывается для конкретной формы входа. Запросы с другими размерами не могут его использовать. Это одна из причин дополнительного расхода памяти, к которому мы вернемся в треке про vLLM.
2. На выходе не тензор. После энкодера начинается отдельный этап span‑декодинга: отфильтровать кандидатов по порогу, разрешить пересечения между спанами, преобразовать токенные индексы обратно в символьные позиции исходного текста и сгруппировать найденные сущности по типам. Эта питоновская логика выполняется на CPU и не переносится на GPU вместе с моделью, также не входит в скомпилированный граф и почти не выигрывает от оптимизаций рассмотренных в статье.
3. В графе есть специфические операции. Работа со спанами включает gather по парам индексов «начало — конец», маскирование невалидных комбинаций и билинейный скоринг относительно заданных меток. Экспортировать такой граф в ONNX можно, но его сложно оптимизировать. Большинство инструментов INT8-квантизации хорошо поддерживает линейные слои и свертки, но не всегда имеют готовые квантованные ядра для операций, расположенных поверх энкодера.Отсюда все провалившиеся INT8-пути ниже.
4. У бэкбона может быть нестандартный attention. Модели этого класса нередко используют бэкбоны с модифицированным механизмом внимания. Например, содержание токена и его позиция могут обрабатываться раздельно. Для NER это хорошо: модель точнее определяет границы сущностей. Вместе с этим с точки зрения инференса такой attention сложнее оптимизировать. Для него нет готового fused‑ядра, поэтому одна логическая операция разворачивается в графе в набор небольших GPU‑операций с дополнительными запусками ядер и обменом данными, то есть получаем много мелких операций вместо одной быстрой. Это ограничение стало причиной отдельного экспериментального трека с Flash DeBERTa.
5. Стоимость батча определяет его самый длинный элемент. Внимание квадратично по длине, паддинг идет до максимума в батче. У декодеров эта проблема решена continuous batching: запросы входят и выходят из батча пошагово, короткие не ждут длинных. У энкодера один проход — выйти из батча посреди него нельзя. Один документ на пятьсот слов держит все остальные шестьдесят три. Поэтому наращивание батча до 64 ухудшило хвосты, не улучшив пропускную способность, а оптимум стал существенно ниже.
6. Вся современная инфраструктура сервинга предполагает генерацию токенов. Многие serving‑движки созданы прежде всего для авторегрессионных LLM. Они управляют KV‑кэшем, разделяют вычислительный бюджет между prefill и decode, измеряют производительность через время до первого токена и скорость последующей генерации.У энкодера принципиально другая модель исполнения. Он целиком читает вход, делает один проход и сразу возвращает результат. Фазы decode у него нет, а KV‑кэш для следующих токенов не требуется. Поэтому часть механизмов, за которые мы платим памятью и сложностью планировщика, в таком сценарии либо не используется, либо не соответствует реальному профилю нагрузки.
7. Целевая метрика меняется из‑за места вставки модели. В обычном LLM‑сервинге как правило максимизируют throughput — количество запросов, которое система способна обработать за единицу времени. Для guard‑слоя этого недостаточно, так как он находится на критическом пути и вызывается дважды за один пользовательский ход. Проверка входа и выдачи модели — пользователь ждет Х2. Следовательно, кроме средней задержки и максимального RPS, крайне важны хвостовые значения latency — прежде всего P95 и P99. Даже редкие медленные запросы напрямую увеличивают время ответа всей системы.
Итак, ниже попытка учесть все инженерные нюансы и найти одно оптимальное решение.
Постановка эксперимента
Чтобы сравнение было честным хотя бы внутри своих категорий, я зафиксировала общую методологию.
Бейзлайнов два, и дальше я буду указывать, с каким из них идёт сравнение:
-
litserve-torch— минимальный сервис на LitServe поверх модели в исходном PyTorch‑формате, 185.3 RPS. Показывает, что дает голый сервинг (использовалась в большинстве треков). -
litserve-onnx-trt— та же модель через ONNX + TensorRT под тем же serving‑слоем, 193.6 RPS (использовалась как baseline в треке Ray Serve). Показывает потолок, до которого можно дойти вообще без смены serving‑фреймворка.
LitServe выбран потому, что он разворачивается за полчаса и почти не добавляет собственной логики — это близко к «просто модель за HTTP». Любой прирост или потеря в экспериментальных треках дальше читается как вклад именно исследуемого инструмента, а не окружающей инфраструктуры.
Упрощенно baseline выглядел так:
Код бейзлайна и формат API
@app.post("/predict")def predict(req: GuardRequest): text = req.text schema = ( model.create_schema() .entities(entity_types=["person", "address", "email", "phone"], threshold=0.4) .classification(task="safety", labels=["safe", "unsafe"]) ) result = model.batch_extract( texts=[text], schemas=schema, batch_size=1, )[0] return result
Формат запроса был минимально простым:
{ "text": "Send $500 to John Smith at john.smith@gmail.com"}
Ответ содержал две смысловые части:
-
список найденных PII‑сущностей
-
итоговую safety‑классификацию текста
Сырой ответ модели в упрощенном виде — список найденных сущностей:
{ "entities": [ { "text": "John Smith", "label": "person", "start": 13, "end": 23, "score": 0.91 }, { "text": "john.smith@gmail.com", "label": "email", "start": 27, "end": 47, "score": 0.97 } ], "safety": "unsafe"}
Для API такой ответ можно дополнительно нормализовать и сгруппировать по типам сущностей:
{ "entities": { "person": ["John Smith"], "address": [], "email": ["john.smith@gmail.com"], "phone": [] }, "safety": "unsafe"}
Данные. Два файла по 500 строк: пользовательские запросы и ответы модели. Тексты длинные и намеренно грязные — техническая тематика с опечатками, лишними словами и обрывками терминов. Длина от 128 до 536 слов, в среднем ~ 320. Для инференс‑экспериментов это принципиально: длина и зашумленность напрямую бьют по latency, а на коротких чистых текстах любая конфигурация выглядит красиво.
Примеры текстов из датасета
Regarding container orchestration with Kubernetes, there are several important aspects to consider. The serialization format was chosen for its compact size and fast encoding and decoding. Input sanitization prevents injection attacks by escaping special characters in user data...
Regarding microservices communication, there are several important aspects to consider. Chaos engineering experiments validate system resilience by injecting controlled failures. The API gateway handles request routing, authentication, and protocol translation...
Стенд. A100 80GB, нагрузка идет с отдельной машины через Locust. Метрики: RPS, P50, P95, P99, error rate.
Задача. Из подмножества возможностей модели было выбрано: извлечение четырех типов персональных данных плюс бинарная классификация безопасности. Модель zero‑shot и в принципе умеет больше, но для бенчмарка нужен зафиксированный сценарий.
Важное ограничение: треки шли на разных профилях нагрузки
Runtime‑трек с TensorRT гонялся коротко: один воркер, 50 пользователей, 60 секунд. Все serving‑треки — Triton, vLLM, Ray Serve, Flash DeBERTa — на длинном профиле: четыре воркера, 100 пользователей, 15 минут.
Такая конфигурация отражала разные задачи треков. В случае TensorRT оценивалась производительность изолированного runtime, а в serving‑треках — поведение многоворкерной системы под продолжительной нагрузкой.
Результат один: числа из TensorRT‑трека нельзя ставить в один столбец с числами из serving‑треков. Ни как рейтинг, ни в сравнении. Каждый трек сравнивается только сам с собой и со своим бейзлайном.
Если будете ставить похожий эксперимент — фиксируйте единый профиль нагрузки с самого начала, это очевидно, но тем не менее я напишу эту мысль. Это в итоге позволит ранжировать инструменты между собой, а не только каждый относительно своей базовой версии, как пришлось мне.
Трек 1. TensorRT
Здесь проверялся нижний слой — как модель исполняется на железе без изменений в сервинге. В этом эксперименте использовалась версия TensorRT 10.9.0 и три способа исполнить один и тот же граф: PyTorch как есть, ONNX Runtime, TensorRT. Каждый в двух точностях, FP16 и INT8.
Профиль короткий: один воркер, 50 пользователей, 60 секунд.
|
Вариант |
RPS |
P50, мс |
P95, мс |
P99, мс |
Errors |
|
TensorRT FP16 |
130.72 |
360 |
420 |
420 |
0 |
|
TensorRT INT8 |
130.56 |
360 |
420 |
430 |
0 |
|
PyTorch FP16 |
106.49 |
450 |
520 |
520 |
0 |
|
ONNX INT8 |
2.49 |
19 000 |
22 000 |
22 000 |
0 |
|
PyTorch INT8 |
1.93 |
21 000 |
32 000 |
36 000 |
10 |
|
ONNX FP16 |
1.68 |
27 000 |
32 000 |
33 000 |
0 |
TensorRT FP16 против PyTorch FP16 — 130.72 против 106.49 RPS, плюс 23% пропускной способности и минус 90 мс на P50. Это эффект компиляции: TensorRT профилирует несколько вариантов ядра для каждой операции и оставляет самый быстрый именно на этой карте, а PyTorch собирает граф динамически на каждом вызове и работает универсальными ядрами.
Две строки в этой таблице — это сломанный стенд
TensorRT INT8 практически совпал с FP16 — 130.56 против 130.72 RPS, разница внутри шума. Так INT8 выглядеть не должен. Восьмибитные ядра либо дают заметный прирост, либо не собираются вовсе и падают обратно в FP16. Второе вероятнее: как я писала во вводной части, span‑скоринг поверх энкодера состоит из операций, для которых INT8-ядер просто нет. Следовательно, квантуется линейная часть, вокруг нее расставляются узлы конвертации, и выигрыш съедается на них же. Проверить это можно по verbose‑логу сборки engine. Я этого не сделала, поэтому корректная формулировка такая: INT8 в TensorRT у меня не поехал, а не «не дал прироста». Вывод для постановки задачи: логируйте сборку.
Строки с ONNX — 1.68 и 2.49 RPS при P50 в 19–27 секунд — это не характеристика ONNX Runtime. Рантайм такого класса не может быть в шестьдесят раз медленнее PyTorch на той же карте. Так выглядит конфигурационная ошибка: скорее всего провайдер исполнения ушел на CPU, либо граф пересобирался под каждую новую форму входа из‑за динамических осей (см. пункт № 1 в начале статьи). До конца не разбиралась — к этому моменту TensorRT‑вариант уже работал.
Оставляю эти строки в таблице чтобы показать, как выглядит на дашборде неправильно приготовленный рантайм. Читать их как «ONNX медленный» нельзя.
Вывод по треку. В случае успешной конвертации, компиляция под конкретный GPU может дать ~ 25% прироста пропускной способности без изменений в самой модели. Из всех экспериментов статьи этот способ ускорения оказался самым дешевым. Но на практике нужно поддерживать весь conversion path: после изменения модели пересобирать TensorRT engine, а для новых диапазонов размеров входа — добавлять или пересматривать профили min/opt/max.
Квантизация в INT8 на такой архитектуре не работает из коробки ни в одном из проверенных путей. Дальше будет еще два подтверждения этому.
Трек 2. Flash DeBERTa: ускорение бэкбона
Прежде чем оптимизировать исполнение модели снаружи, я решила попробовать ускорить ее изнутри. Поэтому здесь рассмотрена другая модель — мультиязычная GLiNER-2 на DeBERTa‑бэкбоне, то есть архитектура из инженерной особенности № 4: раздельная обработка содержания и позиции токена дает точные границы сущностей и медленный attention.
Flash DeBERTa — попытка закрыть эту проблему: та же математика, но более эффективная реализация на уровне CUDA‑ядер. Попытка выяснить можно ли этим обойтись вместо переезда на TensorRT со всей его сборочной обвязкой.
Профиль длинный: 100 пользователей, 15 минут, max_batch_size=64, batch_timeout=0.05s, 4 воркера на устройство.
|
Конфиг |
RPS |
P95, мс |
Error rate |
|
pytorch‑fp16 |
83.7 |
2000 |
12.95% |
|
onnx‑cuda‑fp16 |
90.8 |
1700 |
0% |
|
pytorch‑fp16-flashdeberta |
105.7 |
1400 |
0% |
|
onnx‑trt‑fp16 |
122.6 |
1200 |
0% |
Flash DeBERTa дала +26% к обычному PyTorch и +16% к ONNX Runtime на CUDA. До TensorRT не дотянулась: 105.7 против 122.6 RPS, разрыв около 16%.
Отдельно стоит посмотреть на первую строку. У базового PyTorch‑пути 12.95% ошибок под этим профилем, то есть каждый восьмой запрос не доехал. Единственная конфигурация в таблице, которая роняет запросы. То есть 83.7 RPS у нее — еще и завышенная цифра: часть нагрузки она просто не обслужила. Flash DeBERTa не только ускоряет, но и вытаскивает тот же путь в ноль ошибок.
INT8: второе подтверждение
Два int8-сценария через ONNX Runtime на CUDA:
|
Конфиг |
RPS |
P95, мс |
Error rate |
|
onnx‑cuda‑dynamic‑int8 |
1.5 |
60 000 |
99.63% |
|
onnx‑cuda‑static‑int8, 20 пользователей |
0.3 |
60 000 |
100% |
Здесь важна вторая строка. Снижение нагрузки в пять раз, до двадцати пользователей, исходя из ожиданий, что система хотя бы начнет отвечать. Получилось 100% ошибок вместо 99.63%.
Это снимает гипотезу про перегрузку — под нагрузкой в пять раз меньше стало еще хуже. Значит, дело не в очереди и не в таймаутах как следствии медленного инференса. Конфигурация нерабочая сама по себе, а шестидесятисекундный P95 в обеих строках это просто потолок таймаута клиента. Причина, вероятнее всего, там же, где и в первом треке: disentangled attention разворачивается в ONNX‑графе в нестандартные операции, которые квантователь не умеет обрабатывать корректно, и на выходе получается граф, который формально собрался, но не исполняется.
Вывод по треку. Flash DeBERTa — компромисс. Прирост меньше, чем у TensorRT, но и цена другая: ставится в существующий PyTorch‑пайплайн, не требует сборки engine, не ломается при смене формы входа. Если у вас DeBERTa‑бэкбон и нет ресурса на поддержку conversion path — вариант рабочий.
Трек 3. vLLM: как завести энкодер в движок, написанный под генерацию
vLLM предназначен под авторегрессионную генерацию. Модель, с которой я экспериментировала, не имеет генерации, растущего контекста, и фазы decode — она читает вход целиком и один раз отдает ответ. Всё, ради чего по сути написан vLLM, ей не нужно.
Зачем тогда vLLM? По следующим причинам: vLLM хорошо утилизирует GPU, и нужна была именно утилизация, и более практическая причина — если модель туда заводится, она попадает в ту же инфраструктуру, что и остальной LLM‑стек, с теми же метриками и тем же деплоем. Одним контуром эксплуатации меньше. Уточню, что для vLLM‑экспериментов применялся стек на базе vllm‑factory и vLLM 0.19.0
Я напомню, что модель у нас на ModernBERT‑бэкбоне, вся целиком чуть больше полугигабайта.
Что сломалось
Стандартный путь vLLM не подходил. Для нестандартных архитектур в экосистеме есть vllm‑factory — плагинное расширение, которое умеет encoder‑like сценарии, кастомный I/O и эндпоинт /pooling вместо генерации. Без этого слоя модель в vLLM не укладывается.
В самом vllm‑factory нужного пути тоже не оказалось: там был плагин под старый GLiNER на DeBERTa и не было ничего под связку ModernBERT + GLiNER-2. Пришлось писать свои — конфиг, маппинг весов, IO‑процессор (это сделал коллега). Плагин потом влили в upstream, так что повторять этот кусок работы уже не нужно.
Структура плагина и регистрация
plugins/mmbert_gliner2/├── init.py # регистрация в vLLM registry├── config.py # конфиг модели├── model.py # энкодер + pooler + маппинг весов└── io_processor.py # парсинг запроса, формирование ответаfrom forge.registration import register_pluginfrom .config import GLiNER2ModernBertConfigfrom .model import GLiNER2ModernBertModelregister_plugin( "mmbert_gliner2", GLiNER2ModernBertConfig, GLiNER2ModernBertModel,)
Лайфхак: соврать движку про количество слоев
vLLM выделяет KV‑кэш пропорционально num_hidden_layers. Логика понятная — чем глубже модель, тем больше состояний надо хранить между шагами генерации. Энкодеру хранить нечего — шагов нет. Но движок об этом не знает и честно резервирует память под кэш, который не будет использован.
Решение оказалось совсем простым: прописать ноль слоев, а реальную глубину положить в отдельное поле, которое читает уже сама модель.
class GLiNER2ModernBertConfig(PretrainedConfig): model_type = "mmbert_gliner2" def init(self, ...): self.num_hidden_layers = 0 # vLLM видит 0 слоёв и не выделяет KV-кэш self.encoder_num_layers = 22 # реальная глубина - в своём поле
Запуск после этого обычный:
vllm serve /path/to/model \ --runner pooling \ --io-processor-plugin mmbert_gliner2_io \ --dtype float16
Что тюнила
Когда модель запустилась, вопрос сместился с «как завести» на «как загрузить GPU». И здесь основной вывод трека оказался не про батчинг.
Я начала с привычных ручек: размер батча, таймаут, максимальная длина. Три серии, шестнадцать конфигураций. Ни одна не побила бейзлайн.
|
Серия |
Что проверяла |
Лучший конфиг |
Throughput |
Avg latency |
|
v1 |
dtype, CUDA graphs, лимит batched tokens |
float16 + cudagraph |
108.6 RPS |
866 мс |
|
v2 |
max‑model‑len, max‑num‑seqs, max‑num‑batched‑tokens |
multi-4x |
124.9 RPS |
752 мс |
|
v3 |
одиночный плотный инстанс против нескольких |
multi-4x |
139.7 RPS |
669 мс |
Прогресс между сериями был, но упирался в потолок. Сдвинулось все только когда я перестала крутить батчинг и занялась тем, как работа распределяется по backend‑процессам и бюджету токенов. Мультиинстансный режим улучшал результат последовательно во всех трех сериях — то есть проблема была в том, что одиночный инстанс не догружал карту, батчинг вторичен.
Практический вывод: для энкодера тюнинг батчинга дает меньше, чем стратегия загрузки GPU.
Результат
|
Конфиг |
RPS |
P50, мс |
Errors |
|
len2048-tokens49k |
181.4 |
440 |
0 |
|
len2048-tokens65k |
179.0 |
430 |
0 |
Имена конфигов расшифровываются как максимальная длина последовательности 2048 и бюджет батча в 49 и 65 тысяч токенов соответственно.
По пропускной способности 181.4 RPS — это ниже обоих бейзлайнов: 185.3 у litserve‑torch и 193.6 у litserve‑onnx‑trt. Зато по медианной задержке это лучший результат в проекте: 440 мс против 500 у litserve‑torch.
То есть vLLM не выиграл гонку по RPS, но дал самый быстрый типичный ответ. Для guard‑слоя это ровно тот компромисс, который имеет смысл.
Поверх лучшего конфига включила CUDA graphs (CUDA 13.0):
|
Метрика |
Изменение |
|
Throughput |
+11.1% |
|
Средняя latency |
−10.4% |
|
P99 |
−28.6% |
|
Пиковый VRAM |
×5.1 |
Минус 28.6% на P99 — здесь самое ценное, учитывая, что для guard‑слоя хвосты и есть целевая метрика. Пятикратный рост памяти звучит малопривлекательно, но учитывая небольшой размер модели (весит всего полгига) — это не так критично, так как пятикратный рост от небольшой базы на A100 80GB остается небольшим. Конфигурация vLLM с CUDA Graphs показала лучший throughput, но потребовала 35 514 MiB пиковой VRAM — примерно в 5,1 раза больше, чем LitServe baseline с 6 946 MiB. На карте поменьше или при нескольких моделях на одном GPU выводы могут быть противоположными.
Вывод по треку. vLLM оказался сильнее, чем я ожидала от инструмента, в чей сценарий модель не попадает ни одним свойством. Плагинный путь рабочий, эндпоинт /pooling делает ровно то, что нужно энкодеру, и после настройки загрузки GPU результат встал в верхнюю часть проекта по медиане.
Цена — в эксплуатации. Нужен свой плагин, который надо поддерживать при обновлениях vLLM. Нужно понимать, как движок считает бюджет токенов, потому что дефолты рассчитаны на другую нагрузку. И нужно следить за памятью: CUDA graphs дают лучшие хвосты, но фиксируют форму входа, а форма у нас плавает по условию задачи.
Если бы у меня был один заход и не было времени на плагин — я бы сюда не пошла. Имея время — вариант интересный.
Трек 4. Triton: где на самом деле было узкое место
Это был мой основной трек, и вопрос здесь стоял не «можно ли выжать больше RPS», а «что дает production‑сервер, если модель уже выбрана и ее нельзя менять». Ответ оказался не про пропускную способность.
Шаг 1. Просто перенести модель в Triton
Самый прямолинейный вариант: взять логику бейзлайна и положить ее в Triton Python backend, не трогая сам пайплайн. Перед этим я прогнала мок‑реализацию, чтобы проверить гипотезы про батчинг без полного деплоя.
|
Вариант |
Воркеры |
RPS |
P50 |
P95 |
P99 |
Errors |
ΔRPS |
|
litserve‑torch, бейзлайн |
4 |
185.3 |
500 |
1500 |
1700 |
0 |
— |
|
Mock Triton |
1 |
145.8 |
840 |
960 |
990 |
0 |
— 21.3% |
|
Triton Python backend, REST |
4 |
146.8 |
600 |
1200 |
1900 |
0 |
— 20.8% |
|
Triton Python backend, gRPC |
4 |
148.2 |
620 |
1000 |
1600 |
0 |
— 20.0% |
Вывод отрицательный: Triton проиграл бейзлайну около 21% пропускной способности. Идея «поставим production‑сервер, и все ускорится само» не работает в случае инференса который все еще завязан на питоновскую обвязку, оверхед съедает ожидаемый выигрыш.
Побочный результат интереснее основного. REST против gRPC по пропускной способности — разница 1–2%, то есть никакой. При том оказалось, что по максимальной задержке gRPC убирает выбросы более чем в 4 раза. В согласованной паре прогонов максимальная latency для REST доходила примерно до 16.7 s, тогда как для gRPC она составляла около 3.85 s.
Для этого пайплайна выбор протокола влияет не на скорость, а на предсказуемость хвостов, и это ровно то, что нужно guard‑слою.
Шаг 2. Разложить пайплайн на стадии
Дальше я разобрала инференс на три стадии и стала масштабировать их по отдельности:
text → preprocessor → encoder → postprocessor → result
|
Конфиг |
RPS |
P50 |
P95 |
P99 |
Errors |
|
1+1+1 |
79.9 |
1200 |
1300 |
1400 |
0 |
|
4+1+4 |
86.0 |
1200 |
1200 |
1500 |
0 |
|
4+2+4 |
124.2 |
790 |
1000 |
1300 |
1 |
|
4+4+4 |
134.6 |
700 |
1100 |
1500 |
0 |
Цифры в конфиге — количество параллельных инстансов каждой стадии в ensemble‑пайплайне Triton, а не что‑либо внутри модели.
Базовая конфигурация 1+1+1 дала всего 79.9 RPS, то есть сама по себе декомпозиция все ухудшила. Четырехкратное масштабирование пре‑ и постпроцессинга (4+1+4) добавило 7.6%, то есть не особо впечатляюще. А вот удвоение энкодера дало +44.5%. Следующее удвоение, с двух до четырёх, — уже только +8.4%, похоже на закон убывающей отдачи.
И тут пришел ответ на вопрос, с которого начался проект. Узкое место оказалось не в питоновской обвязке, на которую я думала первые две недели, а в encoder path. Это же задним числом объясняет и результат первого шага — перенос обвязки в Triton не помог, потому что обвязка ничего и не держала.
Ensemble здесь сработал как диагностика (но не оптимизация). Лучшая конфигурация 4+4+4 бейзлайну все равно проиграла по пропускной способности, но по P95 уже выиграла, 1100 мс против 1500.
Шаг 3. Четыре строчки в конфиге
Поверх конфигурации 4+4+4 с динамическим батчингом, я добавила только это:
optimization { graph { level: 3 }}
|
Конфиг |
P95 |
P99 |
RPS |
|
До graph opt |
1100 |
1500 |
134.6 |
|
После graph opt = 3 |
790 |
970 |
136.1 |
P95 минус 28%, P99 минус 35%, пропускная способность практически не изменилась.
Это лучший результат всей работы по отношению эффекта к затраченным усилиям. Ничего не переписываем, ничего не пересобираем, никакой новой зависимости в проде. Для слоя, у которого целевая метрика — хвосты, это буквально бесплатные тридцать процентов.
Отдельно уточню, потому что это классическая путаница: count: 4 в instance_group с kind: KIND_GPU — это четыре инстанса модели на одной карте, а не четыре карты.
Что не запустилось
TorchScript оказался архитектурно нерабочим. torch.jit.trace падал внутри sdpa_mask на нульмерном тензоре — трассировка не переваривает ModernBERT. Рабочим путем стал экспорт в ONNX через dynamo=True, то есть AOT‑компиляция вместо трассировки.
FP16 для ONNX‑энкодера пришлось откатить. ONNX Runtime уходил в stall, очередь раздувалась, вернулась на FP32. Полезный вывод: fp16 — не универсальная оптимизация, если стек вокруг модели не держит его стабильно, половинная точность делает только хуже.
Вывод по треку. Triton не выиграл по пропускной способности ни в одной конфигурации. Но он подсветил важные моменты — где находится узкое место, и второе — позволил срезать треть хвостовой задержки четырьмя строчками конфига.
Если бы я выбирала инструмент только по RPS, я бы этот трек закрыла после первой таблицы и так ничего бы и не узнала про encoder path.
В эксперименте была использована версия Triton Inference Server 25.01-py3
Трек 5. Ray Serve: зрелый serving‑слой
Здесь я скорее проверяла набор гипотез о том, как ведет себя удобный питоновский serving‑фреймворк. У меня получилось 7 утверждений.
REST без батчинга проигрывает бейзлайну. 34.1 RPS против 193.6 у litserve‑onnx‑trt. Разрыв в 5.7 раза. Без батчинга Ray Serve неконкурентоспособен.
Динамический батчинг не догоняет бейзлайн. Батчинг вытащил Ray с 34.1 до 147.2 RPS, то есть в четыре с лишним раза. Но до 193.6 не дотянул.
gRPC лучше REST по задержкам частично правда. На батче 16 P50 лучше на 5.7%, на 32 разница нулевая, на 64 хвосты стали хуже.
gRPC лучше REST по RPS — тоже частично правда. Батч 16 дает +6.3%, батч 32 — паритет (+1.2%), батч 64 — уже -2.1%. Выигрыш от gRPC условный, а не универсальный, он есть на маленьких батчах и исчезает на больших.
Оптимальный батч 16–32 (но в моем случае). Лучшая точка для четырех воркеров около 16. Рост до 64 ухудшил хвосты и не улучшил пропускную способность — ровно то, что предсказывает инженерная особенность № 5 из вводной части — в батче все ждут самый длинный документ.
Оптимальный таймаут около 0.05 с (тоже для моей конфигурации). Для всех проверенных размеров батча. При 0.01 s батч не успевает наполниться, при 0.10 s запросы лишнее время стоят в очереди.
UniEncoder и BiEncoder частично близки по пропускной способности. В 21 паре конфигураций из 32 разница уложилась в 15%, средний разрыв 12.2% в пользу UniEncoder.
Вывод по треку. Ray Serve — вполне рабочий инструмент, который не делает ничего волшебного. Он удобен для оркестрации, реплик и экспериментов с батчингом без глубокой инфраструктурной экспертизы. Но модель он скорее всего не ускорит: весь его прирост в этом треке пришел от динамического батчинга, то есть от настройки, которую можно получить и с другим инструментом.
Что дал каждый трек
Сводить все в одну таблицу с колонкой RPS нельзя из‑за разных профилей нагрузки. Поэтому таблиц в итоге вышло две.
Runtime‑трек (1 воркер, 50 пользователей, 60 секунд):
|
Решение |
Лучший результат |
Сильная сторона |
Ограничение |
|
TensorRT |
130.72 RPS |
+23% к PyTorch на том же железе |
нужен поддерживаемый conversion path |
|
Flash DeBERTa |
105.7 RPS |
ускорение бэкбона без пересборки пайплайна |
до TensorRT не дотягивает |
Serving‑треки (4 воркера, 100 пользователей, 15 минут):
|
Решение |
Лучший результат |
Сильная сторона |
Ограничение |
|
litserve‑onnx‑trt |
193.6 RPS |
лучший throughput в проекте |
мало места для декомпозиции |
|
litserve‑torch |
185.3 RPS, P50 500 |
простой и на удивление крепкий |
то же |
|
vLLM |
181.4 RPS, P50 440 |
лучшая медианная задержка |
свой плагин, рост VRAM, сложный тюнинг |
|
Ray Serve |
147.2 RPS |
удобная оркестрация |
без преимуществ по скорости |
|
Triton |
136.1 RPS, P95 790 |
лучшие хвосты, диагностика узких мест |
не лидер по пропускной способности |
Главное, что видно из второй таблицы: лучший по RPS, лучший по P50 и лучший по P95 — это три разных инструмента. Triton проигрывает бейзлайну четверть пропускной способности и при этом дает вдвое лучший P95. Для guard‑слоя, где пользователь ждет дважды за ход, важнее второе.
Матрица выбора
Также после всех экспериментов у меня получилась такая матрица выбора. Как шпора на будущее.
|
Если важно |
Смотреть на |
Почему |
|
Максимум производительности на железе |
TensorRT |
лучший raw performance там, где конвертация стабильна |
|
Предсказуемые хвосты |
Triton |
graph optimization, ensemble, контроль над пайплайном |
|
Быстрая медиана без своего рантайма |
vLLM |
сильный результат даже на чужой для него архитектуре |
|
Быстро стартовать и не усложнять |
LitServe |
обогнать его сложнее, чем кажется |
|
Ускорить DeBERTa‑бэкбон малой кровью |
Flash DeBERTa |
компромисс между приростом и сложностью внедрения |
|
Оркестрация, реплики, Python‑стек |
Ray Serve |
зрелый слой, но ускорения не ждите |
Ограничения и что я сделала бы иначе
Единый профиль нагрузки с самого начала. Runtime‑трек гонялся на коротком профиле, serving‑треки на длинном, из‑за чего их числа несопоставимы. Главный методологический долг статьи.
Я не измерила качество. Ни одна цифра выше не отвечает на вопрос, об итоговом качестве работы ускоренной модели — ловит ли она столько же персональных данных, сколько исходная. Для FP16 и graph optimization риск небольшой, для INT8 — прямой и серьезный. Так что все выводы про квантизацию здесь — про то, поехало или не поехало, а не про то, можно ли это ставить в прод.
INT8 в TensorRT я не довела. Совпадение с FP16 почти наверняка означает fallback, а не отсутствие эффекта, и это проверяется логом сборки.
ONNX‑путь в первом треке был сломан. Оставила цифры в таблице как иллюстрацию, но выводов про ONNX Runtime из них сделать не получится.
Вывод
Для меня ключевой вывод: изначальный вопрос «чем ускорить модель» был неправильно поставлен. После того как модель выбрана, почти вся оставшаяся инженерная работа находится не в ней, а вокруг: в рантайме, слое сервинга, батчинге, стратегии загрузки GPU и устойчивости под нагрузкой.
Правильная формулировка звучит еще тривиальнее: какой стек лучше впишется в текущие ограничения — по SLA на хвосты, по времени на интеграцию, по памяти, по тому, кто это потом будет поддерживать, и по цене ошибки. У меня цена ошибки была высокой, а целевой метрикой были хвосты, поэтому выиграл инструмент, который проиграл по пропускной способности.
В каждом случае ограничения будут вероятно другие. Числа из этой статьи вам не подойдут, а порядок проверки гипотез, возможно пригодится.
ссылка на оригинал статьи https://habr.com/ru/articles/1067008/