Инференс и деплой: квантизация, дистилляция, сервинг
Обучение модели — это исследовательский проект с понятным финалом: метрика на валидации перестала расти, чекпоинт сохранён. Инференс — это бесконечная эксплуатация. Обучение вы оплачиваете один раз, инференс — каждый день, каждым запросом, пока продукт жив. Для типичного сервиса суммарная стоимость инференса за год превышает стоимость обучения в десятки раз, и именно поэтому инженерия инференса — отдельная дисциплина со своей математикой, а не «упаковать в Docker и выкатить».
Эта статья про то, как из чекпоинта получить сервис с предсказуемой латентностью, контролируемой стоимостью и понятной процедурой обновления. Мы разберём три класса техник — сжатие модели (квантизация, дистилляция, прунинг), ускорение вычисления (компиляция, слияние ядер, батчинг) и инженерию эксплуатации (сервинг, роллаут, наблюдаемость). Специфика больших языковых моделей — KV-кэш, continuous batching, спекулятивное декодирование — разобрана в статье Большие языковые модели; здесь мы говорим о принципах, общих для CNN, трансформеров, рекуррентных сетей и графовых моделей.
1. Инференс — это другая задача оптимизации
Первое, что нужно осознать: обученная модель и работающая модель оптимизируются по разным критериям, и почти всё, что было хорошо на обучении, на инференсе становится вредным.
| Аспект | Обучение | Инференс |
|---|---|---|
| Что оптимизируем | качество на валидации | стоимость запроса при заданном SLO |
| Метрика успеха | accuracy / loss | p99-латентность, RPS на GPU, $ / 1000 запросов |
| Точность вычислений | нужна для градиентов (BF16 + FP32-мастер) | достаточно INT8/INT4 |
| Батч | большой, произвольный | диктуется латентностью, часто равен 1 |
| Отказ | перезапустить с чекпоинта | 500-я ошибка пользователю |
| Нагрузка | ровная, предсказуемая | пики в 20× по времени суток |
| Живёт | недели | годы |
Второе — у инференса нет обратного прохода. Это освобождает примерно 70 % памяти (градиенты, состояния оптимизатора, активации для backward) и снимает требование к дифференцируемости. Именно поэтому на инференсе допустимы операции, немыслимые при обучении: округление весов до 4 бит, выбрасывание половины нейронов, слияние BatchNorm в предыдущую свёртку.
1.1 Roofline: где на самом деле узкое место
Прежде чем что-то оптимизировать, нужно понять, чего именно ждёт железо. Есть ровно два варианта: оно ждёт вычислений или ждёт память. Разделяет их арифметическая интенсивность — сколько операций с плавающей точкой приходится на байт, прочитанный из памяти.
Возьмём линейный слой y = Wx, где W имеет размер [n, k], а батч равен B.
- FLOPs:
2 · n · k · B(умножение и сложение на каждый элемент). - Байты из памяти:
n · k · bдля весов (b— байт на вес) плюс(k + n) · B · bдля данных. ПокаBмал, доминируют веса. - Интенсивность:
≈ 2B / b.
При B = 1 и BF16 это 1 FLOP на байт. У современного ускорителя точка перелома — сотни
FLOP на байт. Вывод: при малом батче GPU не считает, а ждёт, и утилизация
вычислительных блоков составляет доли процента. Отсюда два следствия, определяющих всю
дальнейшую статью:
- Сокращение объёма весов в памяти (квантизация) даёт прямое ускорение в memory-bound режиме — INT8 вместо BF16 читается вдвое быстрее, даже если арифметика не изменилась.
- Батчинг — самый мощный рычаг: он повышает интенсивность линейно, амортизируя чтение весов на много запросов.
def roofline(flops: float, bytes_moved: float,
peak_tflops: float = 989.0, hbm_tb_s: float = 3.35) -> dict:
"""Оценка достижимой производительности и предел скорости.
Сложность O(1) — это арифметика на салфетке, но она экономит недели
оптимизации не того места.
"""
intensity = flops / bytes_moved # FLOP на байт
ridge = peak_tflops / hbm_tb_s # точка перелома
achievable = min(peak_tflops, hbm_tb_s * intensity)
return {
"интенсивность": round(intensity, 2),
"перелом": round(ridge, 1),
"режим": "compute-bound" if intensity > ridge else "memory-bound",
"достижимо_TFLOPs": round(achievable, 2),
"утилизация_%": round(100 * achievable / peak_tflops, 2),
}
# Модель на 7 млрд параметров, BF16, декодинг по одному токену
N = 7e9
print(roofline(flops=2 * N, bytes_moved=N * 2)) # batch = 1
print(roofline(flops=2 * N * 64, bytes_moved=N * 2)) # batch = 64
# batch=1 → 1 FLOP/байт, memory-bound, утилизация ~0.34 %
# batch=64 → 64 FLOP/байт, всё ещё memory-bound, но в 64 раза лучше
Запомните это как рефлекс: прежде чем оптимизировать, посчитайте интенсивность. Половина усилий по «ускорению инференса» в индустрии тратится на compute-оптимизации в memory-bound режиме и не даёт ничего.
1.2 Карта методов
2. Квантизация: главный рычаг
Квантизация — замена чисел с плавающей точкой на целые с меньшей разрядностью. FP32 → INT8 даёт четырёхкратное сокращение памяти и трафика; FP16 → INT4 — тоже четырёхкратное. В memory-bound режиме это почти линейно переходит в ускорение.
2.1 Числовые форматы: что во что упирается
| Формат | Бит | Диапазон | Точность (мантисса) | Где применяют |
|---|---|---|---|---|
| FP32 | 32 | ~1e±38 | 24 бита | эталон, мастер-веса |
| TF32 | 19 | как FP32 | 11 бит | matmul на Ampere+ |
| FP16 | 16 | ~1e±5 | 11 бит | инференс CV, риск переполнения |
| BF16 | 16 | как FP32 | 8 бит | обучение и инференс по умолчанию |
| FP8 (E4M3) | 8 | ~±448 | 4 бита | Hopper/Blackwell, веса+активации |
| INT8 | 8 | −128…127 | равномерная сетка | классика PTQ, все платформы |
| INT4 / NF4 | 4 | 16 уровней | равномерная / нормальная сетка | веса LLM, edge |
Ключевое отличие BF16 от FP16: BF16 жертвует мантиссой ради экспоненты. Динамический диапазон совпадает с FP32, поэтому переполнения и «NaN на 40-й эпохе» практически исчезают — именно за это его и любят. FP16 точнее в мантиссе, но требует масштабирования лосса при обучении. Подробности — в статье Обучение сетей.
2.2 Математика аффинной квантизации
Квантизация — это отображение отрезка вещественных чисел [α, β] на целочисленную сетку.
Формально, для целевого типа с диапазоном [q_min, q_max]:
s = (β − α) / (q_max − q_min) # шаг сетки (scale)
z = round(q_min − α / s) # целое, в которое отображается вещественный ноль
q = clip(round(x / s) + z, q_min, q_max)
x̂ = s · (q − z) # деквантизация
Ошибка |x − x̂| ≤ s/2 при отсутствии клиппинга. Отсюда весь инженерный смысл: уменьшая
s, вы уменьшаете ошибку, но s пропорционален ширине диапазона β − α. Значит, вся
борьба — за то, чтобы диапазон был узким, а масса значений в него укладывалась.
Важный частный случай — симметричная схема: z = 0, β = −α = max|x|. Она чуть менее
точна для сдвинутых распределений, но убирает из матричного умножения перекрёстный член с
zero-point. Разложим:
(s_x (q_x − z_x)) · (s_w q_w) = s_x s_w (q_x q_w − z_x q_w)
При симметричной схеме для весов остаётся ровно одно целочисленное matmul плюс скалярное умножение на выходе. При асимметричной — появляется дополнительная сумма по весам, которую можно предпосчитать, но ядро усложняется. На практике: веса — симметрично, активации — асимметрично (после ReLU/GELU распределение односторонее, и симметрия выбросила бы половину сетки).
2.3 Гранулярность: где живёт качество
- Per-tensor — один
sна всю матрицу. Самый быстрый вариант, но одна «плохая» строка портит всю матрицу. - Per-channel — свой
sна каждую выходную строкуW. Практически бесплатно (масштаб выносится за пределы внутреннего цикла) и почти всегда обязателен для INT8. - Group-wise — свой
sна группу из 64/128 подряд идущих весов. Основа INT4: накладные расходы16/128 = 0,125бита на вес при FP16-масштабе, зато качество близко к FP16.
Реализуем всё это руками — понимание кода здесь стоит десяти абзацев теории.
import torch
def quantize_affine(x: torch.Tensor, n_bits: int = 8,
symmetric: bool = True, dim: int | None = None):
"""Аффинная квантизация тензора.
dim=None → per-tensor, dim=0 → per-channel по строкам.
Возвращает целые коды, масштаб и zero-point.
Сложность: O(n) по времени, O(n) по памяти (плюс масштабы).
"""
q_min, q_max = (-(2 ** (n_bits - 1)), 2 ** (n_bits - 1) - 1)
if dim is None:
amin, amax = x.min(), x.max()
else:
# сводим все оси кроме dim: получаем по масштабу на канал
other = [i for i in range(x.ndim) if i != dim]
amin = x.amin(dim=other, keepdim=True)
amax = x.amax(dim=other, keepdim=True)
if symmetric:
scale = torch.maximum(amax.abs(), amin.abs()) / q_max
zero = torch.zeros_like(scale)
else:
scale = (amax - amin) / (q_max - q_min)
zero = torch.round(q_min - amin / scale)
scale = scale.clamp(min=1e-8) # защита от нулевого канала
q = torch.clamp(torch.round(x / scale) + zero, q_min, q_max)
return q.to(torch.int8 if n_bits == 8 else torch.int32), scale, zero
def dequantize(q: torch.Tensor, scale: torch.Tensor, zero: torch.Tensor):
return (q.float() - zero) * scale
def sqnr_db(x: torch.Tensor, x_hat: torch.Tensor) -> float:
"""Signal-to-Quantization-Noise Ratio — рабочая метрика качества квантизации.
Ниже 20 дБ на слой — почти наверняка проблемы. Выше 35 дБ — обычно безопасно."""
noise = (x - x_hat).pow(2).mean()
return float(10 * torch.log10(x.pow(2).mean() / noise.clamp(min=1e-20)))
torch.manual_seed(0)
W = torch.randn(512, 512) * 0.02
W[17] *= 30.0 # один «горячий» канал — как в реальности
for name, kwargs in [("per-tensor", {}), ("per-channel", {"dim": 0})]:
q, s, z = quantize_affine(W, n_bits=8, **kwargs)
print(f"{name:12} INT8: SQNR = {sqnr_db(W, dequantize(q, s, z)):5.1f} дБ")
for name, kwargs in [("per-tensor", {}), ("per-channel", {"dim": 0})]:
q, s, z = quantize_affine(W, n_bits=4, **kwargs)
print(f"{name:12} INT4: SQNR = {sqnr_db(W, dequantize(q, s, z)):5.1f} дБ")
# Разница между per-tensor и per-channel на INT4 достигает 15–20 дБ:
# это ровно та граница, за которой модель начинает нести чушь.
2.4 PTQ: квантизация после обучения
Post-Training Quantization не требует ни данных с метками, ни обучения. Два подвида:
- Динамическая — веса квантуются заранее, масштабы активаций считаются на лету по фактическому батчу. Не нужна калибровка, но есть накладные расходы на каждый forward. Идеальна для NLP-моделей на CPU, где памятью правит матрица эмбеддингов.
- Статическая — масштабы активаций фиксируются заранее на калибровочном наборе (100–1000 репрезентативных примеров без меток). Быстрее в рантайме, но чувствительна к тому, насколько калибровочные данные похожи на прод.
Выбор диапазона по min/max калибровочной выборки — плохая идея: один выброс задирает s.
Правильно искать диапазон, минимизирующий ошибку.
def search_clipping_range(x: torch.Tensor, n_bits: int = 8, n_grid: int = 80):
"""Подбор порога отсечения по критерию минимума MSE (как в TensorRT/AWQ).
Идея: обрезать хвост распределения выгоднее, чем растягивать сетку под выброс.
Ошибка = ошибка округления (растёт с диапазоном) + ошибка клиппинга (падает).
Сложность O(n_grid · n), выполняется офлайн один раз на слой.
"""
best, best_err = None, float("inf")
amax = x.abs().max().item()
for i in range(1, n_grid + 1):
thr = amax * i / n_grid
xc = x.clamp(-thr, thr)
q, s, z = quantize_affine(xc, n_bits=n_bits, symmetric=True)
err = (x - dequantize(q, s, z)).pow(2).mean().item() # ошибка меряется к ОРИГИНАЛУ
if err < best_err:
best, best_err = thr, err
return best, best_err
act = torch.randn(100_000) * 0.5
act[torch.randint(0, 100_000, (12,))] = 14.0 # редкие выбросы
thr, err = search_clipping_range(act)
print(f"min/max дал бы порог 14.0, а MSE-оптимум = {thr:.2f} — сетка в {14/thr:.1f}× плотнее")
Практический рецепт калибровки, в порядке возрастания качества:
- MinMax — только как baseline.
- Percentile 99,9 % — дёшево и почти всегда лучше MinMax.
- MSE / энтропийный (KL) поиск — стандарт в TensorRT.
- GPTQ (Frantar et al., 2022) — квантует веса послойно, корректируя ещё не обработанные веса так, чтобы компенсировать уже внесённую ошибку (по сути, приближённый метод второго порядка на гессиане слоя). Даёт рабочий INT4.
- AWQ (Lin et al., 2023) — замечает, что важность веса определяется величиной активаций, а не самого веса, и масштабирует «важные» каналы перед квантизацией. Быстрее GPTQ, качество сопоставимо.
- SmoothQuant (Xiao et al., 2022) — математически
переносит «сложность» с активаций на веса:
Y = (X / d)(d · W). Активации становятся квантуемыми, веса — чуть менее, суммарно выигрыш. Это решает главную боль INT8-активаций в трансформерах, описанную в LLM.int8().
2.5 QAT: обучение с учётом квантизации
Когда PTQ теряет слишком много (обычно при INT4 без групп, при 2–3 битах или на маленьких
моделях вроде MobileNet, где нет избыточности), применяют Quantization-Aware Training.
В прямой проход вставляются «фальшивые» квантайзеры: x → dequant(quant(x)). Модель
учится в присутствии шума округления и находит веса, устойчивые к нему.
Проблема: round() имеет нулевую производную почти всюду. Решение —
Straight-Through Estimator: в обратном проходе округление считается тождественной
функцией.
class FakeQuantSTE(torch.autograd.Function):
"""Квантизация в прямом проходе, тождественность (с клиппингом) в обратном."""
@staticmethod
def forward(ctx, x, scale, q_min, q_max):
q = torch.clamp(torch.round(x / scale), q_min, q_max)
ctx.save_for_backward(x, scale)
ctx.bounds = (q_min, q_max)
return q * scale
@staticmethod
def backward(ctx, grad_out):
x, scale = ctx.saved_tensors
q_min, q_max = ctx.bounds
# Градиент пропускаем только внутри диапазона: вне его выход не зависит от входа.
mask = (x >= q_min * scale) & (x <= q_max * scale)
return grad_out * mask, None, None, None
class QuantizedLinear(torch.nn.Linear):
"""Linear с fake-quant весов (per-channel) и активаций (per-tensor)."""
def forward(self, x):
w_scale = self.weight.abs().amax(dim=1, keepdim=True).clamp(min=1e-8) / 127
w_q = FakeQuantSTE.apply(self.weight, w_scale, -127, 127)
x_scale = x.abs().amax().clamp(min=1e-8) / 127
x_q = FakeQuantSTE.apply(x, x_scale, -127, 127)
return torch.nn.functional.linear(x_q, w_q, self.bias)
QAT обычно требует 1–10 % бюджета исходного обучения: берётся готовый чекпоинт и
дообучается с малым LR. Более продвинутый вариант —
LSQ, где сам scale становится обучаемым параметром.
2.6 Trade-offs квантизации
| Метод | Данные | Время | Типичная потеря | Когда брать |
|---|---|---|---|---|
| Динамическая INT8 | не нужны | минуты | < 1 % | CPU-сервинг, быстрая победа |
| Статическая INT8 PTQ | 100–1000 без меток | часы | 0,5–2 % | GPU/edge, зрелый стандарт |
| GPTQ / AWQ INT4 | 128 примеров | десятки минут | 1–3 % | LLM-веса, экономия VRAM |
| FP8 | не нужны | минуты | < 0,5 % | Hopper/Blackwell, без перекалибровки |
| QAT INT8 | размеченные | дни | ~0 % | мобильные, малые модели |
| QAT INT4/INT2 | размеченные | дни-недели | 2–5 % | жёсткие ограничения edge |
Типичные ошибки, которые видишь в каждом втором проекте:
- Калибровка не на тех данных. Калибруетесь на train, а прод другого распределения — масштабы активаций мимо, метрики падают необъяснимо.
- Квантизация всего подряд. Первый и последний слои, softmax, LayerNorm и функция потерь почти всегда остаются в FP16/FP32. Смешанная точность — норма, а не компромисс.
- Замер «ускорения» без реального ядра. Fake-quant в PyTorch не ускоряет ничего: он добавляет операции. Ускорение появляется только когда рантайм действительно исполняет INT8-ядро (TensorRT, ONNX Runtime, bitsandbytes, marlin).
- Проверка качества по одному агрегату. Средняя accuracy держится, а качество на редких классах или длинных запросах разваливается. Проверяйте срезами.
- INT4 без групп. Per-tensor INT4 — почти всегда мусор. Группа 128 — рабочий минимум.
3. Дистилляция: обучить маленькую модель у большой
Квантизация сжимает представление весов. Дистилляция сжимает саму функцию: маленькая модель («ученик») учится воспроизводить поведение большой («учителя»).
Ключевая интуиция из работы
Hinton, Vinyals, Dean, 2015: жёсткая метка
класс = «седан» несёт log₂(N) бит информации, а полное распределение учителя
седан 0,9; купе 0,08; грузовик 0,001; кошка 1e-9 несёт ещё и структуру задачи — что
на что похоже. Это «тёмное знание» (dark knowledge), и оно даёт градиент гораздо более
информативный, чем one-hot.
Чтобы малые вероятности не тонули в экспоненте, softmax смягчают температурой T:
p_i = softmax(z_i / T)
L = α · T² · KL(p_учитель ‖ p_ученик) + (1 − α) · CE(y, ученик)
Множитель T² компенсирует то, что градиенты смягчённого softmax масштабируются как 1/T²
— без него при смене T пришлось бы менять и learning rate.
import torch.nn.functional as F
def distillation_loss(student_logits, teacher_logits, targets,
T: float = 4.0, alpha: float = 0.9):
"""Классический KD-лосс Хинтона.
T: температура. 2–10 для классификации, 1–2 для языковых моделей.
alpha: вес мягкой цели. 0.9 — разумный старт, если учитель заметно сильнее ученика.
"""
soft = F.kl_div(
F.log_softmax(student_logits / T, dim=-1),
F.log_softmax(teacher_logits / T, dim=-1),
reduction="batchmean",
log_target=True, # численно устойчивее, чем передавать вероятности
) * (T * T)
hard = F.cross_entropy(student_logits, targets)
return alpha * soft + (1 - alpha) * hard
# Схема обучения: учитель заморожен и в eval, логиты можно предпосчитать офлайн
# и сложить на диск — тогда обучение ученика идёт с обычной скоростью,
# без forward большой модели на каждом шаге. Экономия — до 5× времени.
3.1 Разновидности
- По ответам (response-based) — то, что выше. Универсально, не требует совместимости архитектур.
- По представлениям (feature-based) — ученик дополнительно повторяет промежуточные активации учителя (FitNets, а для трансформеров — TinyBERT). Требует проекции размерностей и выбора «какие слои с какими сопоставлять», зато переносит больше.
- По вниманию — сопоставляются матрицы attention. Хорошо работает для трансформеров, см. DistilBERT (6 слоёв вместо 12, 97 % качества GLUE, 60 % скорости) и MiniLM.
- Self-distillation — учитель и ученик одной архитектуры; работает как регуляризация и часто немного улучшает качество.
- Task-specific дистилляция на неразмеченных данных — самый недооценённый приём в проде. Большая модель (или даже LLM через API) размечает миллион ваших логов, маленькая специализированная модель учится на этой разметке. Итог: модель на 100 МБ, решающая одну задачу лучше, чем универсальный гигант, при стоимости в тысячи раз ниже.
3.2 Когда дистилляция не работает
- Ученик слишком мал. Если у ученика нет ёмкости для функции, никакой учитель не поможет — это ограничение снизу по ёмкости представления.
- Нет данных для переноса. KD работает на данных, и их нужно много — но метки не нужны, что резко удешевляет процесс.
- Учитель ошибается системно. Ученик наследует и ошибки, и предвзятости учителя. Это прямой мост к теме интерпретируемости и безопасности.
- Разрыв доменов. Учитель уверен там, где ученик не имеет нужных признаков.
Практика: дистилляция и квантизация комбинируются. Сначала дистиллируем 70B в 7B, потом квантуем 7B в INT4. Суммарное сжатие — 40×, потеря качества на целевой задаче — единицы процентов.
4. Прунинг и разреженность
Третий путь — просто выбросить часть весов. У обученной сети огромная избыточность: до 90 % весов можно занулить с минимальной потерей качества.
- Неструктурный прунинг зануляет отдельные веса (обычно наименьшие по модулю). Даёт лучшее соотношение «сжатие / качество», но не ускоряет на GPU: произвольная разреженность не отображается на плотные тензорные ядра. Экономит только диск, если хранить в разреженном формате.
- Структурный прунинг удаляет целые головы внимания, каналы свёрток, слои. Сжатие скромнее, зато ускорение реальное — модель просто становится меньше.
- Разреженность 2:4 — компромисс от NVIDIA (Ampere+): в каждой четвёрке весов ровно два нуля. Такой паттерн поддержан аппаратно и даёт до 2× на matmul при потере обычно < 1 % после дообучения.
import torch.nn.utils.prune as prune
layer = torch.nn.Linear(1024, 1024)
# Неструктурный: убрать 50 % наименьших по модулю весов
prune.l1_unstructured(layer, name="weight", amount=0.5)
# Структурный: убрать 30 % выходных нейронов целиком (по L2-норме строки)
prune.ln_structured(layer, name="weight", amount=0.3, n=2, dim=0)
prune.remove(layer, "weight") # зафиксировать маску в весах, убрать обёртку
print("доля нулей:", (layer.weight == 0).float().mean().item())
# Полуструктурная 2:4 для тензорных ядер:
# from torch.sparse import to_sparse_semi_structured
# w = to_sparse_semi_structured(mask_2to4(layer.weight))
Итеративный рецепт (он же «lottery ticket»-подход, Frankle & Carbin, 2018): обрезать 20 % → дообучить → повторить. Обрезать 90 % за один шаг почти всегда фатально, а десятью шагами — обычно нет.
5. Компиляция графа: ускорение без потери точности
Всё предыдущее меняло модель. Компиляция — нет: это чисто механическое ускорение, и его стоит делать всегда и первым, потому что оно бесплатно по качеству.
Что делает компилятор:
- Слияние ядер (kernel fusion).
Conv → BatchNorm → ReLUисполняется как три запуска ядер с записью промежуточных тензоров в HBM и чтением обратно. Слитое ядро читает вход один раз. Для memory-bound операций это буквально кратное ускорение. - Свёртка BatchNorm в веса. На инференсе BN — аффинное преобразование с константами, его можно аналитически влить в предыдущую свёртку. Слой исчезает бесплатно.
- Constant folding — всё, что не зависит от входа, считается один раз при компиляции.
- Подбор алгоритма и раскладки. Для каждой формы тензора выбирается лучшее ядро и layout (NCHW vs NHWC), иногда — авто-тюнингом на вашем железе.
- CUDA Graphs — запись всей последовательности запусков ядер и её воспроизведение одной командой. Снимает накладные расходы на запуск (существенно при малых батчах, где CPU не успевает кормить GPU).
import torch
model = MyNet().eval().cuda()
# 1. Самый дешёвый шаг: компиляция прямо в PyTorch
compiled = torch.compile(model, mode="max-autotune", fullgraph=True)
# 2. Экспорт в ONNX — переносимый формат для чужих рантаймов
example = torch.randn(1, 3, 224, 224, device="cuda")
torch.onnx.export(
model, example, "model.onnx",
input_names=["input"], output_names=["logits"],
dynamic_axes={"input": {0: "batch"}, "logits": {0: "batch"}}, # динамический батч!
opset_version=17, dynamo=True,
)
# 3. Правильный замер: прогрев + синхронизация. Без них цифры бессмысленны.
def benchmark(fn, x, warmup: int = 20, iters: int = 200) -> float:
for _ in range(warmup): # компиляция ядер, autotune, разогрев кэшей
fn(x)
torch.cuda.synchronize() # без этого измерите время постановки в очередь
start = torch.cuda.Event(enable_timing=True)
end = torch.cuda.Event(enable_timing=True)
start.record()
for _ in range(iters):
fn(x)
end.record()
torch.cuda.synchronize()
return start.elapsed_time(end) / iters # мс на итерацию
with torch.inference_mode(): # строже, чем no_grad: отключает и version counter
print("eager :", benchmark(model, example), "мс")
print("compiled:", benchmark(compiled, example), "мс")
# Дальше по лестнице ускорения — специализированные рантаймы.
# TensorRT: агрессивное слияние + автотюнинг под конкретную SM-архитектуру.
trtexec --onnx=model.onnx --saveEngine=model.plan \
--fp16 --int8 --calib=calib.cache \
--minShapes=input:1x3x224x224 \
--optShapes=input:16x3x224x224 \
--maxShapes=input:64x3x224x224
# ВАЖНО: .plan привязан к версии TensorRT и модели GPU.
# Собирайте его в том же образе, в котором запускаете, иначе получите
# «works on my A100, crashes on L4».
# ONNX Runtime — кроссплатформенно (CPU, CUDA, DirectML, CoreML, WebAssembly)
python -m onnxruntime.quantization.preprocess --input model.onnx --output model-opt.onnx
Порядок применения на практике:
baseline → torch.compile → экспорт → INT8 PTQ → замер → (если мало) дистилляция → (если мало) QAT.
Каждый шаг дороже предыдущего, и останавливаться нужно, как только SLO выполнен.
6. Сервинг: архитектура и решения
Оптимизированная модель — это ещё не сервис. Сервис — это то, что держит нагрузку, не падает от пиков и обновляется без даунтайма.
6.1 Как выбрать конфигурацию
горизонтальный автоскейлинг] E -->|Нет| G{Квантизация спасает?} G -->|Да, INT8/INT4| F G -->|Нет| H[Tensor parallel
нужен NVLink между GPU] F --> I{p99 в SLO?} H --> I I -->|Да| J[Заморозить конфиг
нагрузочный тест, canary] I -->|Нет| K{Что показывает профайлер?} K -->|Ждём память| L[Квантизация весов
слияние ядер, кэш префиксов] K -->|Ждём вычисления| M[FP8, разреженность 2:4
меньшая модель] K -->|Ждём в очереди| N[Больше реплик
приоритетные очереди, backpressure] K -->|Ждём CPU| O[CUDA Graphs, препроцессинг
в отдельный пул, zero-copy] L --> I M --> I N --> I O --> I J --> P{Качество на проде держится?} P -->|Да| Q[Полный роллаут] P -->|Нет| R[Откат, разбор срезов
перекалибровка] R --> J
6.2 Динамический батчинг
Ключевая идея сервинга нейросетей: запросы приходят по одному, а GPU эффективен на батчах.
Сервер накапливает запросы в окне max_queue_delay и исполняет их вместе. Это осознанный
обмен: мы добавляем каждому запросу до T миллисекунд задержки, чтобы поднять
пропускную способность в разы.
до истечения окна Q->>G: батч [A, B, C] одним прогоном G->>G: forward: веса читаются 1 раз на 3 запроса G-->>Q: 3 результата Q-->>U1: ответ (ждал 5 + 12 мс) Q-->>U2: ответ (ждал 3 + 12 мс) Q-->>U3: ответ (ждал 1 + 12 мс) Q->>M: batch_size=3, queue_time, compute_time Note over Q,G: Пик нагрузки — очередь растёт U1->>Q: запрос при заполненной очереди alt очередь длиннее порога Q-->>U1: 429 Too Many Requests + Retry-After Note over Q: backpressure честнее,
чем таймаут через 30 секунд else есть место Q->>G: в следующий батч end
Правило настройки: max_queue_delay должен быть меньше вашего бюджета p99 минус время
вычисления. Если SLO — 100 мс, а forward занимает 40, окно в 20 мс безопасно, а в 80 —
нет. И всегда меряйте нагрузочным тестом, а не интуицией.
# Triton Inference Server: config.pbtxt — канонический пример
name: "classifier"
platform: "tensorrt_plan"
max_batch_size: 64
dynamic_batching {
preferred_batch_size: [ 8, 16, 32 ] # размеры, под которые собран движок
max_queue_delay_microseconds: 5000 # 5 мс — потолок доп. задержки
default_queue_policy {
max_queue_size: 256
timeout_action: REJECT # честный отказ вместо накопления очереди
default_timeout_microseconds: 50000
}
}
instance_group [ { count: 2, kind: KIND_GPU, gpus: [ 0 ] } ] # 2 копии на GPU: перекрываем H2D-копирование и счёт
model_warmup [ {
name: "warmup"
batch_size: 8
inputs { key: "input" value { data_type: TYPE_FP32, dims: [3,224,224], zero_data: true } }
} ] # без прогрева первые запросы после старта ловят холодные ядра и ловят таймаут
6.3 Слои кэширования
Самый быстрый инференс — тот, которого не было.
- Кэш результатов по хэшу входа — тривиален, но в реальных продуктах покрывает 10–40 % трафика (популярные запросы распределены по Ципфу).
- Семантический кэш — поиск по эмбеддингу запроса с порогом близости. Осторожно: ложное срабатывание выдаёт пользователю чужой ответ. Порог должен быть консервативным, а кэш — разделён по пользователю/тенанту.
- Кэш признаков — предпосчитанные эмбеддинги товаров/пользователей в key-value хранилище, чтобы на запросе считать только «свежую» часть. Стандарт в рекомендательных системах.
- Кэш префиксов для LLM — общий системный промпт считается один раз, см. статью про LLM.
6.4 Где считать: GPU, CPU, edge
| Сценарий | Платформа | Что учесть |
|---|---|---|
| Большие модели, высокий RPS | GPU-сервер | амортизация дорогая, нужен батчинг и высокая утилизация |
| Малые модели, невысокий RPS | CPU | INT8-динамическая квантизация, ONNX Runtime; часто дешевле GPU |
| Мобильное приложение | CoreML / TFLite / NNAPI | приватность, офлайн, батарея; жёсткий лимит на размер |
| Браузер | WebGPU / ONNX Runtime Web | размер загрузки решает всё; INT8 обязателен |
| Микроконтроллер | TFLite Micro | килобайты памяти, INT8 или ничего |
Мысль, которую часто упускают: GPU не всегда дешевле. Для модели на 50 МБ с 20 RPS пул CPU-подов с автоскейлингом обычно экономичнее, чем недогруженный GPU, а по p99 — предсказуемее.
7. Жизненный цикл версии модели
Модель в проде — это не файл, а версия, у которой есть состояния и переходы. Смешивать деплой кода и деплой модели нельзя: у них разные риски и разные процедуры отката.
тесты на регресс Валидирована --> Отклонена: метрика ниже базовой
или деградация на срезе Отклонена --> [*] Валидирована --> Оптимизирована: квантизация,
компиляция, экспорт Оптимизирована --> Валидирована: проверка ПОСЛЕ оптимизации
обязательна Оптимизирована --> Shadow: копия трафика,
ответы не отдаются Shadow --> Canary: латентность и метрики
совпали с ожиданием Shadow --> Отклонена: расхождение офлайн/онлайн Canary --> Canary: 1% → 5% → 25%
наблюдаем на каждом шаге Canary --> Продакшн: бизнес-метрики не просели Canary --> Откат: рост ошибок,
p99 или жалоб Откат --> Валидирована: разбор причины Продакшн --> Устаревшая: вышла следующая версия Устаревшая --> Продакшн: аварийный откат
(держим N-1 горячей) Устаревшая --> [*]: срок хранения истёк note right of Shadow Shadow ловит то, чего не видно офлайн: реальное распределение, битые входы, латентность под настоящей нагрузкой end note
Shadow-режим — самая недооценённая практика. Вы отправляете копию продового трафика в новую версию, ответы выбрасываете, но пишете метрики. Стоит денег, зато ловит ровно те проблемы, которые офлайн-валидация принципиально не видит: реальное распределение входов, битые и вредоносные запросы, поведение под настоящим профилем нагрузки.
7.1 Что мониторить
Три уровня, и все три обязательны:
- Инфраструктура: p50/p95/p99 латентности (среднее бесполезно), RPS, глубина очереди, доля 5xx, утилизация GPU, память, температура/троттлинг.
- Данные: дрейф входного распределения (PSI, KL-дивергенция по признакам), доля пропусков, доля запросов вне обучающего домена, распределение длин.
- Модель: распределение предсказаний, средняя уверенность, доля отказов/фолбэков, доля срабатываний кэша, а где возможно — отложенная онлайн-метрика (CTR, конверсия, доля правок пользователем).
import numpy as np
def psi(expected: np.ndarray, actual: np.ndarray, bins: int = 10) -> float:
"""Population Stability Index — рабочая метрика дрейфа признака.
< 0.1 — стабильно
0.1–0.25 — умеренный дрейф, стоит присмотреться
> 0.25 — значимый дрейф, модель, вероятно, уже деградирует
Сложность O(n log n) из-за квантилей; считается по расписанию на семпле.
"""
edges = np.quantile(expected, np.linspace(0, 1, bins + 1))
edges[0], edges[-1] = -np.inf, np.inf
e = np.histogram(expected, edges)[0] / len(expected)
a = np.histogram(actual, edges)[0] / len(actual)
eps = 1e-6 # иначе деление на ноль на пустых корзинах
e, a = np.clip(e, eps, None), np.clip(a, eps, None)
return float(np.sum((a - e) * np.log(a / e)))
ref = np.random.normal(0, 1, 100_000) # обучающее распределение
print("та же выборка :", round(psi(ref, np.random.normal(0.0, 1, 50_000)), 3))
print("сдвиг на 0.5σ :", round(psi(ref, np.random.normal(0.5, 1, 50_000)), 3))
Отдельно: логируйте входы и выходы с семплированием. Без сохранённых примеров разбор инцидента «модель начала отдавать чушь» превращается в гадание, а разметка накопленных логов — это ваш следующий обучающий набор.
8. Приоритеты: что делать первым
Не все оптимизации равны по отношению «выигрыш / трудозатраты».
Практический порядок: сначала измерить, потом батчинг и кэш, потом компиляция, потом квантизация, и только если этого не хватило — дистилляция и QAT. Обратный порядок — классический способ потратить квартал и получить 5 %.
9. Типичные ошибки
- Оптимизация без профилирования. Три недели на кастомное ядро, а 60 % времени запроса — это JPEG-декодирование на CPU. Всегда начинайте с трассировки всего пути запроса, а не forward-прохода модели.
- Замер без прогрева и синхронизации. CUDA асинхронна: без
torch.cuda.synchronize()вы измеряете время постановки задачи в очередь. Первые итерации включают компиляцию ядер. - Средняя латентность вместо p99. Пользователи ощущают хвост. Средняя в 50 мс при p99 в 4 секунды — это плохой сервис, и по средней вы этого не увидите.
- Валидация только до оптимизации. Квантизованная модель — это другая модель. Полный прогон офлайн-метрик и срезов обязателен после каждого шага сжатия.
- Один агрегат вместо срезов. Accuracy упала на 0,4 %, но целиком за счёт длинных текстов или редкого языка — который и был бизнес-задачей.
- Игнорирование препроцессинга. Ресайз, токенизация, нормализация нередко дороже самой модели. И расхождение между препроцессингом на обучении и в проде — источник ошибок №1, который не ловится ни одним тестом модели.
- Нет плана отката. Предыдущая версия должна оставаться развёрнутой и горячей. Откат должен быть переключением флага, а не пересборкой.
- Пренебрежение холодным стартом. Автоскейлинг поднял под, а он 90 секунд грузит веса и компилирует ядра — весь этот трафик получил таймауты. Лечится прогревом, предзагруженными образами и запасом реплик.
- Смешивание интерактивной и пакетной нагрузки в одном пуле. Одна батч-джоба съедает весь батч-бюджет и рушит SLO чата.
- Нефиксированные версии. Модель + токенизатор + препроцессинг + версия рантайма — единый неделимый артефакт. Обновление одной части независимо от других рано или поздно создаёт инцидент, который отлаживается неделю.
10. Как это выглядит в проде
Сложившиеся практики зрелых команд:
- Артефакт модели неделим и версионирован. Веса, конфиг препроцессинга, токенизатор, версия рантайма, хэш обучающего кода — один пакет в реестре моделей (MLflow, W&B, S3 с манифестом). Воспроизводимость важнее удобства.
- Оптимизация — часть CI. Пайплайн: обучение → офлайн-валидация → квантизация → повторная валидация с порогами → сборка движка → нагрузочный тест → артефакт. Регресс качества больше порога останавливает пайплайн автоматически.
- Два пула нагрузки. Интерактивный (малый батч, малое окно, запас мощности) и пакетный (большой батч, spot-инстансы, прерываемый). Разные SLO — разное железо.
- Роллаут через shadow → canary. Никогда сразу 100 %. Автоматический откат по триггерам латентности и ошибок.
- Фолбэк-лестница. Если основная модель недоступна или медленна: лёгкая модель → кэшированный ответ → эвристика → честная ошибка. Деградация вместо отказа.
- Бюджет на запрос как продуктовая метрика.
$ / 1000 запросов— на одном дашборде с качеством. Любая оптимизация оценивается в этих терминах, иначе спор «качество против стоимости» превращается в спор о вкусах. - Регулярное переобучение по расписанию и по триггеру дрейфа. Дрейф неизбежен; вопрос только в том, узнаете вы о нём из мониторинга или из тикета поддержки.
11. Мини-итог
- Инференс оптимизируется по стоимости и p99-латентности, а не по accuracy; это отдельная инженерная дисциплина.
- Сначала измерьте арифметическую интенсивность. В memory-bound режиме (а декодинг с малым батчем всегда там) compute-оптимизации бесполезны.
- Квантизация — главный рычаг: аффинное отображение
x̂ = s(q − z), качество определяется шириной диапазона и гранулярностью масштаба. Веса — симметрично per-channel, активации — асимметрично; INT4 требует групп по 64–128. - PTQ решает большинство задач за часы; QAT нужен для агрессивных режимов и малых моделей; STE — трюк, делающий недифференцируемое округление обучаемым.
- Дистилляция переносит «тёмное знание» через смягчённые softmax-распределения и особенно сильна в task-specific варианте на неразмеченных логах.
- Прунинг без структуры экономит диск, но не время; ускоряет структурный и 2:4.
- Компиляция бесплатна по качеству — делайте её первой.
- Батчинг обменивает латентность на пропускную способность; окно ожидания настраивается под бюджет p99, а не наугад.
- Деплой модели — это жизненный цикл с shadow, canary, мониторингом дрейфа и мгновенным откатом. Артефакт неделим и версионирован.
12. Источники
- Квантизация: A Survey of Quantization Methods for Efficient Neural Network Inference, Quantizing deep convolutional networks (Krishnamoorthi, 2018), LLM.int8(), GPTQ, AWQ, SmoothQuant, LSQ.
- Дистилляция: Distilling the Knowledge in a Neural Network, DistilBERT, TinyBERT, MiniLM.
- Прунинг: The Lottery Ticket Hypothesis, Accelerating Sparse Deep Neural Networks (2:4).
- Системы и roofline: Roofline: An Insightful Visual Performance Model, Making Deep Learning Go Brrrr From First Principles, PagedAttention / vLLM.
- Документация: PyTorch quantization, torch.compile, ONNX Runtime, TensorRT, Triton Inference Server, bitsandbytes.
- Эксплуатация: Designing Machine Learning Systems — Chip Huyen; Machine Learning Design Patterns; Rules of Machine Learning — Google.
- Смежное на портале: Инженерия данных про пайплайны и качество данных, Паттерны архитектуры про очереди, backpressure и деградацию.
Что дальше
Мы научились делать модель быстрой, дешёвой и надёжно обновляемой. Но быстрая модель, принимающая неверные решения, опаснее медленной: чем дешевле инференс, тем шире область применения и тем выше цена системной ошибки. Остался последний — и самый неудобный — вопрос: можно ли понять, почему модель ответила именно так, и что произойдёт, если её специально попытаться сломать? Об этом финальная статья трека: Интерпретируемость, устойчивость и безопасность моделей.