Нейронные сети Инференс и деплой: квантизация, дистилляция, сервинг
0%

Инференс и деплой: квантизация, дистилляция, сервинг

Инференс и деплой: квантизация, дистилляция, сервинг

Обучение модели — это исследовательский проект с понятным финалом: метрика на валидации перестала расти, чекпоинт сохранён. Инференс — это бесконечная эксплуатация. Обучение вы оплачиваете один раз, инференс — каждый день, каждым запросом, пока продукт жив. Для типичного сервиса суммарная стоимость инференса за год превышает стоимость обучения в десятки раз, и именно поэтому инженерия инференса — отдельная дисциплина со своей математикой, а не «упаковать в 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: где на самом деле узкое место

Прежде чем что-то оптимизировать, нужно понять, чего именно ждёт железо. Есть ровно два варианта: оно ждёт вычислений или ждёт память. Разделяет их арифметическая интенсивность — сколько операций с плавающей точкой приходится на байт, прочитанный из памяти.

Roofline-модель: memory-bound и compute-bound режимы инференса

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

  1. Сокращение объёма весов в памяти (квантизация) даёт прямое ускорение в memory-bound режиме — INT8 вместо BF16 читается вдвое быстрее, даже если арифметика не изменилась.
  2. Батчинг — самый мощный рычаг: он повышает интенсивность линейно, амортизируя чтение весов на много запросов.
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 Математика аффинной квантизации

Квантизация — это отображение отрезка вещественных чисел [α, β] на целочисленную сетку.

Аффинная квантизация: масштаб, zero-point и проблема выбросов

Формально, для целевого типа с диапазоном [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}× плотнее")

Практический рецепт калибровки, в порядке возрастания качества:

  1. MinMax — только как baseline.
  2. Percentile 99,9 % — дёшево и почти всегда лучше MinMax.
  3. MSE / энтропийный (KL) поиск — стандарт в TensorRT.
  4. GPTQ (Frantar et al., 2022) — квантует веса послойно, корректируя ещё не обработанные веса так, чтобы компенсировать уже внесённую ошибку (по сути, приближённый метод второго порядка на гессиане слоя). Даёт рабочий INT4.
  5. AWQ (Lin et al., 2023) — замечает, что важность веса определяется величиной активаций, а не самого веса, и масштабирует «важные» каналы перед квантизацией. Быстрее GPTQ, качество сопоставимо.
  6. 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, ученик)

Множитель компенсирует то, что градиенты смягчённого 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. Компиляция графа: ускорение без потери точности

Всё предыдущее меняло модель. Компиляция — нет: это чисто механическое ускорение, и его стоит делать всегда и первым, потому что оно бесплатно по качеству.

Что делает компилятор:

  1. Слияние ядер (kernel fusion). Conv → BatchNorm → ReLU исполняется как три запуска ядер с записью промежуточных тензоров в HBM и чтением обратно. Слитое ядро читает вход один раз. Для memory-bound операций это буквально кратное ускорение.
  2. Свёртка BatchNorm в веса. На инференсе BN — аффинное преобразование с константами, его можно аналитически влить в предыдущую свёртку. Слой исчезает бесплатно.
  3. Constant folding — всё, что не зависит от входа, считается один раз при компиляции.
  4. Подбор алгоритма и раскладки. Для каждой формы тензора выбирается лучшее ядро и layout (NCHW vs NHWC), иногда — авто-тюнингом на вашем железе.
  5. 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 Как выбрать конфигурацию

6.2 Динамический батчинг

Ключевая идея сервинга нейросетей: запросы приходят по одному, а GPU эффективен на батчах. Сервер накапливает запросы в окне max_queue_delay и исполняет их вместе. Это осознанный обмен: мы добавляем каждому запросу до T миллисекунд задержки, чтобы поднять пропускную способность в разы.

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

7.1 Что мониторить

Три уровня, и все три обязательны:

  1. Инфраструктура: p50/p95/p99 латентности (среднее бесполезно), RPS, глубина очереди, доля 5xx, утилизация GPU, память, температура/троттлинг.
  2. Данные: дрейф входного распределения (PSI, KL-дивергенция по признакам), доля пропусков, доля запросов вне обучающего домена, распределение длин.
  3. Модель: распределение предсказаний, средняя уверенность, доля отказов/фолбэков, доля срабатываний кэша, а где возможно — отложенная онлайн-метрика (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. Источники

Что дальше

Мы научились делать модель быстрой, дешёвой и надёжно обновляемой. Но быстрая модель, принимающая неверные решения, опаснее медленной: чем дешевле инференс, тем шире область применения и тем выше цена системной ошибки. Остался последний — и самый неудобный — вопрос: можно ли понять, почему модель ответила именно так, и что произойдёт, если её специально попытаться сломать? Об этом финальная статья трека: Интерпретируемость, устойчивость и безопасность моделей.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов