Bollywood HungamaEXCLUSIVE: CBFC clears Drishyam The Conclusion WITHOUT a single cut; is 8 minutes longer than Drishyam 2ESPNThe only MLB playoff preview you need: World Series odds, likely MVPs and how far all 12 teams will goThe Jerusalem PostTeenager wounded in Holon after explosive device detonates, police launch investigationESPN DeportesLando Norris se disculpó con Franco ColapintoPunchTerrorists attacking Plateau communities observing curfew, NBA allegesRTP DesportoAPAF exige medidas após agressão a árbitroEgypt IndependentProposal to harvest organs from Egypt’s death-row inmates sparks controversyWirtualna Polska"To już oficjalne". Trzaskowski komentuje koniec zbiórki ws. referendumSouth China Morning PostChild and 2 women ‘crushed to death’ during France to UK sea crossingThe South AfricanGetting your driver’s licence? Waiting times at every testing centre in Cape Town20 MinutenWohnung zu heiss: Ueli Schmezer fordert Miet-RabattDW DeutschWadephul gibt Strafgerichtshof nach US-Attacke Rückendeckung
The Daily Newsstand · Free, Always
Monday, September 28, 2026

Почему инференс LLM становится дорогим и как снизить расходы на GPU без покупки новых карт

Translate

Всем привет, меня зовут Сергей Прощаев и в этой статье расскажу про то, как снизить стоимость инференса LLM на собственных GPU, не меняя чекпойнт модели и парк карт.

Я Tech Lead и руководитель направления Java | Kotlin разработки в FinTech & E‑commerce и преподаю на курсах разработки и архитектуры в ОТУС.

Картина, которую я видел уже не раз. Команда за вечер поднимает открытую модель на vLLM, подключает к ней ассистента поддержки, демо проходит отлично. Через месяц приходят два сообщения.

  • От финансов: аренда GPU обходится как зарплата трёх разработчиков.

  • От продукта: в час пик клиенты ждут первый токен по 15–20 секунд.

Вы открываете nvidia-smi, видите утилизацию под 100% и делаете вывод, что нужны ещё карты.

Стоп! Часто проблема не в количестве GPU, а в том, что они снова и снова пересчитывают одни и те же длинные промпты.

Ниже маршрут оптимизации инференса LLM из шести шагов: как найти узкое место, убрать повторную работу и доказать эффект цифрами.

На выходе будет стоимость GPU‑часов на миллион токенов до и после, TTFT в пределах SLA и обоснованный ответ на вопрос, нужны ли новые карты. Чекпойнт и парк GPU не меняются, меняются только конфигурация сервинга и точность весов.

Рис. 1. Куда утекают деньги в инференсе LLM: GPU пересчитывают одно и то же, а запросы стоят в очереди

Рис. 1. Куда утекают деньги в инференсе LLM: GPU пересчитывают одно и то же, а запросы стоят в очереди

Исходные условия

  • Модель: Llama 3.3 70B Instruct, BF16‑чекпойнт, развёрнутый у себя. На ней все расчёты ниже.

  • Железо: две реплики по 2×H100 SXM 80 GB. В каждой реплике оба GPU стоят на одной ноде и связаны NVLink, всего 4 GPU.

  • Стек: vLLM (ветка V1), Kubernetes, Envoy Gateway, Prometheus, Grafana, DCGM Exporter.

  • Нагрузка: ассистент поддержки с RAG, системный промпт около 3 000 токенов, многоходовые диалоги, сотни одновременных пользователей.

  • SLA: TTFT p95 (время до первого токена) не больше 2 секунд.

Маршрут рассчитан на нагрузку, где начало промпта повторяется от запроса к запросу. Если промпты уникальны, а ответы длинные, шаги 4 и 5 дадут мало.

Как это понять заранее, покажу в шаге 1.

План действий

Порядок шагов важен не меньше, чем сами шаги. На Рис. 2 показано, в какой последовательности я иду и где стоят развилки.

Рис. 2. План действий: от базовой линии до продвинутых рычагов

Рис. 2. План действий: от базовой линии до продвинутых рычагов

Главное на этой схеме — замкнутый контур «замер — изменение — повторный замер». Без него любая оптимизация превращается в спор мнений, а продвинутые рычаги стоит трогать только тогда, когда базовые уже выжаты и это видно в цифрах.

Шаг 1. Снимаем базовую линию и находим узкое место

Риск: оптимизировать вслепую и не суметь доказать эффект.

Сначала о том, почему разговор начинается с памяти. В статье MLOps‑инженера из hh.ru тезис сформулирован так: эффективность инференса определяется тем, как вы управляете памятью видеокарты, а выбор модели вторичен.

В разборе Cloud.ru приводятся характеристики H100: 3,35 ТБ/с пропускной способности памяти при 989 TFLOPS в FP16. По оценке автора, при генерации токенов используется лишь около 10–20% этой вычислительной мощности.

nvidia-smi здесь плохой советчик. Его GPU‑Util показывает долю времени, когда на карте выполнялось хоть какое‑то вычислительное ядро, а не то, насколько она загружена полезной работой.

Для оценки насыщения я смотрю DCGM‑метрики профилирования: DCGM_FI_PROF_DRAM_ACTIVE (загрузка памяти) и DCGM_FI_PROF_SM_ACTIVE (активность вычислительных блоков).

Главная экономическая метрика — GPU‑часы на миллион выходных токенов при соблюдении SLA. Считаю её как все сгенерированные токены, делённые на все GPU‑часы за один и тот же интервал, а в деньги перевожу через внутреннюю ставку часа GPU: аренда или амортизация, электричество, доля эксплуатации.

Пиковую пропускную способность тоже снимаю, но она нужна для планирования ёмкости, а не для стоимости: высокий перцентиль дал бы слишком оптимистичную цену токена.

Важная оговорка. Если после оптимизации улучшился только TTFT, а пропускная способность при том же SLA не выросла, токен не подешевел. Деньги экономятся, когда освобождённое время GPU превращается в большее число обслуженных запросов на тех же картах.

Оптимизация инференса — только одна часть работы с ML‑системами. Пройдите короткий бесплатный тест по MLOps, чтобы оценить свои знания и понять, где есть пробелы.

# (Python) GPU-only стоимость 1M выходных токенов за неделю; ставка условная
GPU_HOUR_PRICE = 2.0          # внутренняя ставка часа одной H100
GPUS = 4                      # 2 реплики x 2 GPU
HOURS = 24 * 7                # тот же интервал, что и в PromQL ниже
TOKENS_WEEK = 907_200_000     # sum(increase(vllm:generation_tokens_total[7d]))

gpu_hours_per_1m = GPUS * HOURS / (TOKENS_WEEK / 1_000_000)
print(f"{gpu_hours_per_1m:.3f} GPU-ч на 1M токенов, "
      f"{gpu_hours_per_1m * GPU_HOUR_PRICE:.2f} за 1M")   # 0.741 GPU-ч, 1.48 за 1M
# (PromQL) недельная базовая линия; имена сверяйте с /metrics своей версии vLLM.
# В документации счётчики записаны без суффикса, в экспозиции Prometheus у них есть _total.

# все выходные токены за неделю — основа для стоимости
sum(increase(vllm:generation_tokens_total[7d]))

# p95 пропускной способности за неделю — для планирования ёмкости, не для стоимости
quantile_over_time(0.95, sum(rate(vllm:generation_tokens_total[5m]))[7d:5m])

# TTFT p95 за неделю
histogram_quantile(0.95, sum by (le) (increase(vllm:time_to_first_token_seconds_bucket[7d])))

# hit rate префиксного кэша: доля попаданий среди токенов, для которых проверялся кэш
sum(increase(vllm:prefix_cache_hits_total[7d])) / sum(increase(vllm:prefix_cache_queries_total[7d]))

# сколько входных токенов приходится на один выходной — быстрый индикатор
sum(increase(vllm:prompt_tokens_total[7d])) / sum(increase(vllm:generation_tokens_total[7d]))

# где тратится время запроса: prefill против decode (p95 за неделю)
histogram_quantile(0.95, sum by (le) (increase(vllm:request_prefill_time_seconds_bucket[7d])))
histogram_quantile(0.95, sum by (le) (increase(vllm:request_decode_time_seconds_bucket[7d])))

# пиковая очередь за неделю: по кластеру и по худшей реплике
max_over_time(sum(vllm:num_requests_waiting)[7d:1m])
max_over_time(max(vllm:num_requests_waiting)[7d:1m])

Соотношение входных и выходных токенов — мой быстрый предварительный индикатор. Если на один выходной токен приходится по десятку входных, а hit rate низкий, скорее всего, деньги уходят на повторный prefill.

Подтверждаю это фазовыми метриками vLLM: время prefill и decode на запрос.

  • Если заметную долю времени запроса занимает prefill, основной эффект дадут шаги 4 и 5.

  • Если доминирует decode, нагрузка упирается в генерацию, prefix caching почти не поможет, и работать нужно с памятью и батчингом (шаги 2, 3 и 6).

Каждый повторный замер я провожу по одному протоколу:

  1. тот же чекпойнт, токенизатор и chat template;

  2. та же топология GPU (в нашем случае NVLink внутри ноды); смена топологии — отдельный эксперимент;

  3. то же распределение длин входа и выхода (лучше всего — повтор реального трафика);

  4. та же интенсивность запросов и конкурентность;

  5. прогрев перед замером, холодный старт в итог не входит;

  6. не меньше трёх прогонов на конфигурацию;

  7. фиксируем TTFT p95, время на токен, пропускную способность, долю ошибок и вытеснений (vllm:num_preemptions_total).

Проверка: есть недельная базовая линия (стоимость 1M токенов, TTFT p95, hit rate, пиковая очередь) и понятно, во что упирается нагрузка: в prefill или в decode.

Шаг 2. Считаем KV‑кэш до того, как просить новые карты

Риск: упереться в память и лечить это покупкой железа.

KV‑кэш растёт линейно с длиной последовательности и числом одновременных запросов. Для Llama 3 70B на токен приходится около 320 КиБ кэша, последовательность в 4 096 токенов занимает примерно 1,25 ГиБ, а батч из 32 таких последовательностей — около 40 ГиБ.

Мой вариант, который я обычно использую, — короткий скрипт по config.json модели:

# (Python) теоретическая ёмкость KV-кэша одной реплики
import json

GIB = 1024**3
cfg = json.load(open("config.json"))          # Llama 3.3 70B
layers   = cfg["num_hidden_layers"]           # 80
kv_heads = cfg.get("num_key_value_heads", cfg["num_attention_heads"])   # 8
head_dim = cfg.get("head_dim", cfg["hidden_size"] // cfg["num_attention_heads"])  # 128

seq_len = 8192                    # полная длина: промпт + сгенерированные токены
gpu_gib = 80e9 / GIB              # H100 80 GB ~ 74.5 GiB
gpus, util = 2, 0.90              # реплика: 2 x H100, gpu-memory-utilization
weights_gib = 70e9 / GIB          # 70B в FP8, приблизительно
free_gib = gpu_gib * gpus * util - weights_gib   # без активаций, CUDA graphs, NCCL

for name, dtype_bytes in (("FP16 KV", 2), ("FP8 KV", 1)):
    per_token = 2 * layers * kv_heads * head_dim * dtype_bytes   # K и V
    per_seq_gib = per_token * seq_len / GIB
    print(f"{name}: {per_seq_gib:.2f} GiB/посл., до ~{int(free_gib // per_seq_gib)} посл.")
# FP16 KV: 2.50 GiB/посл., до ~27 посл.
# FP8 KV:  1.25 GiB/посл., до ~55 посл.

Здесь важно правильно читать gpu-memory-utilization. Это бюджет памяти, который выделяется конкретному экземпляру vLLM целиком: под веса, активации, служебные структуры и KV‑кэш. Это не «90% памяти под кэш».

Поэтому результат скрипта — верхняя оценка при заданных допущениях. Реальную конкурентность определяет планировщик, а настоящий потолок вы увидите в нагрузочном тесте по vllm:kv_cache_usage_perc и росту вытеснений.

Проверка: оценка ёмкости, умноженная на число реплик, покрывает пиковую конкурентность с запасом. Как стартовый запас я беру 20–30%, а точнее его задают исторические p99 всплесков конкурентности и целевой SLA. Если запаса нет, не спешите за картами: сначала шаги 3 и 4.

Шаг 3. Настраиваем движок под свою нагрузку

Риск: держать дефолты, которые съедают память впустую.

Continuous batching vLLM даёт из коробки: новые запросы встают в батч, как только освобождается место. В разборе Cloud.ru приводится пример, где загрузка GPU выросла благодаря этому с 30–40% до 80–90%.

Остальное настраивается под нагрузку.

# (YAML) аргументы vLLM в Deployment одной реплики; флаги сверяйте с `vllm serve --help`
args:
  - "--model=meta-llama/Llama-3.3-70B-Instruct"
  - "--tensor-parallel-size=2"        # оба GPU на одной ноде, NVLink
  - "--quantization=fp8"              # онлайн-квантование весов BF16 -> FP8; проверяется в шаге 6
  - "--kv-cache-dtype=fp8"            # KV-кэш вдвое компактнее; тоже через eval-гейт
  - "--max-model-len=16384"           # по контракту API, а не по окну модели
  - "--gpu-memory-utilization=0.90"   # бюджет памяти экземпляра vLLM целиком
  - "--enable-prefix-caching"         # в V1 включён по умолчанию, пишу явно
  - "--max-num-seqs=64"               # стартовое значение для нагрузочного теста
  - "--max-num-batched-tokens=8192"   # стартовое значение для этой нагрузки

Значения max-num-seqs и max-num-batched-tokens здесь — стартовые точки, а не выведенные из шага 2 константы. max-num-seqs одновременно определяет размер буферов и CUDA graphs и ограничивает число одновременно исполняемых запросов. max-num-batched-tokens — бюджет токенов на один шаг планировщика.

Оба подбираются нагрузочным тестом.

Разберу флаги, которые дают больше всего.

  • max-model-len. У Llama 3.3 70B окно контекста 128K, но лимит должен соответствовать контракту вашего API и реальному распределению контекстов. Помню, как однажды на ревью конфигурации увидел 128K при реальных диалогах до 6 тысяч токенов. Один клиент вставил в чат выгрузку логов на сотню тысяч токенов, забрал KV‑кэш у десятков соседей, и очередь выросла у всех.

  • quantization=fp8. Онлайн‑квантование прежде всего вдвое сокращает память под веса и освобождает место под KV‑кэш, то есть под конкурентность. Выигрыш по латентности при динамическом масштабировании активаций может быть скромным, поэтому эффект подтверждаю замером.

  • kv-cache-dtype=fp8. Из шага 2 видно, что он удваивает ёмкость. Но у FP8 KV есть масштабирующие коэффициенты для K и V. Проверьте, откуда они берутся в вашей версии vLLM: если откалиброванных масштабов нет, используются значения по умолчанию, и качество может просесть. Поэтому FP8 KV проходит тот же eval‑гейт, что и веса.

  • Параметры планировщика. Их я подбираю по симптомам в метриках, а не наугад:

Симптом

Куда смотреть

TTFT растёт, длинные промпты забивают шаг планировщика

max-num-batched-tokens, chunked prefill

Время на токен растёт при нормальном TTFT

max-num-seqs, размер батча decode

KV‑кэш почти заполнен, растут вытеснения

max-model-len, FP8 KV, конкурентность

Очередь растёт при нормальных TTFT и времени на токен

не хватает ёмкости: реплики или шаги 4–5

Про выбор движка скажу коротко. Автор из hh.ru по своим бенчмаркам рекомендует SGLang для H100 и новее, а vLLM для Ampere и старше.

Я бы рассматривал это как гипотезу и сравнивал движки на своей нагрузке только после того, как узкое место найдено и флаги настроены. Переезд займёт месяц, а флаги — один вечер.

Проверка: в пик очередь по кластеру около нуля, заполненность KV‑кэша не прилипает к потолку, вытеснения не растут. Пропускная способность при соблюдении SLA выросла относительно шага 1.

Шаг 4. Делаем промпт кэшируемым

Риск: префиксный кэш включён, но ничего не кэширует.

Prefix caching в vLLM переиспользует KV‑блоки только для совпадающего начала последовательности токенов и ускоряет prefill, а не генерацию. Если от запроса к запросу меняются контекст, порядок чанков или текст инструкций, переиспользовать почти нечего.

Помню, как мы полдня искали, почему при включённом prefix caching доля попаданий держалась на уровне пары процентов.

Причина нашлась в первой строке системного промпта: туда подставляли текущие дату и время с точностью до минуты. Три тысячи токенов правил шли после неё, и каждую минуту кэш начинался с нуля.

# (Python) было: изменяющаяся строка в начале ломает префикс
from datetime import datetime, date

system = f"""Ты ассистент банка. Сейчас {datetime.now():%d.%m.%Y %H:%M}.
Клиент: {user.name}, сегмент {user.segment}.
{POLICY_TEXT}"""                       # 3 000 токенов правил после «живой» строки

# стало: неизменное в начале, меняющееся в конце
chunks = sorted(retrieved, key=lambda c: (-c.score, c.doc_id))  # ранжирование сохраняем,
context = "\n\n".join(c.text for c in chunks)                   # doc_id только для ничьих

def build_messages(question):
    return [
        {"role": "system", "content": POLICY_TEXT},   # одинаковые токены у всех
        *history,
        {"role": "user", "content": f"[дата: {date.today():%d.%m.%Y}, "
                                    f"сегмент: {user.segment}]\n"
                                    f"Документы:\n{context}\n\nВопрос: {question}"},
    ]

Правило простое. Всё, что одинаково у всех пользователей, стоит в начале и даёт одни и те же токены. Всё, что зависит от пользователя или времени, уходит в конец. Порядок найденных в RAG чанков должен быть детерминированным, но ранжирование по релевантности ломать ради кэша нельзя.

Проверять нужно итоговую последовательность токенов после chat template. В неё входят описания инструментов (tools) и всё, что шаблон добавляет в системный блок. Например, шаблон Llama 3.x пишет туда дату, и если передавать в него текущую, префикс будет меняться каждые сутки.

# (Python) сравниваем токены двух запросов после применения chat template
from transformers import AutoTokenizer

tok = AutoTokenizer.from_pretrained("meta-llama/Llama-3.3-70B-Instruct")
a = tok.apply_chat_template(build_messages("Как закрыть вклад?"), tools=TOOLS,
                            tokenize=True, return_dict=False)
b = tok.apply_chat_template(build_messages("Где выписка?"), tools=TOOLS,
                            tokenize=True, return_dict=False)

common = next((i for i, (x, y) in enumerate(zip(a, b)) if x != y), min(len(a), len(b)))
print(f"общий префикс: {common} токенов из {len(a)}")

Безопасность общего кэша. Prefix caching между разными пользователями создаёт побочный канал: по TTFT можно косвенно понять, лежит ли в кэше чужой префикс. Для финтеха это не теоретический вопрос. vLLM закрывает этот канал параметром запроса cache_salt: запросы с разной солью не делят кэш. Область соли выбирается по границе доверия.

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

  • Общая случайная соль для группы (например, тенанта) сохраняет переиспользование внутри неё.

  • Без соли кэш делится между всеми — это допустимо только для однотенантной системы.

# (Python) cache_salt в запросе к vLLM через OpenAI-совместимый клиент
resp = client.chat.completions.create(
    model="meta-llama/Llama-3.3-70B-Instruct",
    messages=build_messages(question),
    extra_body={"cache_salt": tenant_salt},   # случайная соль на границу доверия
)

Проверка: hit rate из шага 1 приблизился к доле стабильного префикса во входных токенах. В нашем примере системный промпт — около 3 000 из 4 000 входных токенов первого хода, поэтому около 70% — это ориентировочная верхняя граница для первого хода. На неё влияют прогрев, вытеснения, размер блока, соль и роутинг, а многоходовые диалоги при удачном роутинге добавят сверху. Если значение заметно ниже, ищите изменяющиеся токены в начале промпта.

Шаг 5. Учим балансировщик помнить про кэш

Риск: на одной реплике всё работает, а на нескольких кэш размазан по кластеру.

В базовой конфигурации KV‑кэш принадлежит конкретной реплике vLLM (внешние KV‑коннекторы вроде LMCache меняют картину, но это отдельная архитектура).

Обычный Kubernetes Service ничего не знает о содержимом кэшей. Запрос с тем же системным промптом может попасть на под, который этот промпт ещё не видел, и кластер снова тратит GPU на тот же prefill.

В июне 2026 года мне попался показательный кейс.

Инженер поднял Qwen2.5–7B‑Instruct на vLLM в AWS EKS на восьми нодах с одной A10G на каждой. Он сравнил обычный ClusterIP Service, который в его конфигурации распределял запросы по кругу, с роутером llm‑d в режиме precise prefix‑cache routing. Поды были одни и те же. В нагрузке было 150 повторяющихся префиксов по 2 048 токенов и 512 одновременных запросов.

  • С llm‑d прогон занял 358,7 секунды вместо 840,2.

  • Доля попаданий в кэш выросла примерно с 11% до 93%, очередь сократилась примерно со 180 запросов до нуля, среднее TTFT упало на 95%.

Это результат одной нагрузки с сильно повторяющимися префиксами, а не универсальный коэффициент. Автор отдельно отмечает, что в итоговые перцентили попал прогрев холодного кэша. Нашлась и полезная деталь для эксплуатации: размер блока в vLLM и в скорере роутера должен совпадать.

Разница между двумя подходами показана на Рис. 3.

Рис. 3. Схема принципиальная: балансировка без учёта кэша против роутинга по префиксу

Рис. 3. Схема принципиальная: балансировка без учёта кэша против роутинга по префиксу

Главная мысль схемы: при нескольких репликах кэш работает только тогда, когда балансировщик знает, где что лежит. Без этого включённый prefix caching на каждой реплике даёт копейки.

Здесь важно различать два механизма.

  • Привязка сессии возвращает запросы одного диалога на одну реплику и сохраняет кэш его истории.

  • Роутинг по префиксу отправляет разных пользователей с одинаковым началом промпта туда, где этот префикс уже лежит.

Они не заменяют друг друга и могут работать вместе.

Идея не новая. Character.AI в посте об оптимизации инференса описывала кэширование KV между ходами диалога: средний запрос у них тянет за собой историю примерно из 180 сообщений.

На уровне парка серверов запросы одного диалога через sticky sessions уходят на один и тот же сервер.

В нашей ситуации я бы начал с привязки сессии через консистентное хеширование по идентификатору диалога:

# (YAML) Envoy Gateway: запросы одного диалога идут на одну реплику.
# Синтаксис по примеру из документации Envoy Gateway; сверяйте с API своей версии.
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: BackendTrafficPolicy
metadata:
  name: llm-session-affinity
spec:
  targetRefs:
    - group: gateway.networking.k8s.io
      kind: HTTPRoute
      name: llm-route
  loadBalancer:
    type: ConsistentHash
    consistentHash:
      type: Header
      header:
        name: x-session-id

Заголовок x-session-id должен появиться до того, как Envoy выбирает под. Его проставляет доверенный слой перед балансировкой: auth‑прокси перед Envoy или сам Gateway из проверенного JWT.

Значение, пришедшее от внешнего клиента, отбрасывается. Консистентное хеширование при этом не даёт вечной привязки: при добавлении или удалении реплик часть сессий переедет на другие поды.

Переходить к роутингу по префиксу я бы стал по сигналу из метрик, а не по числу реплик. Сигнал такой: hit rate по кластеру заметно ниже, чем на одной реплике под той же нагрузкой, и вместе с этим растут очередь и TTFT.

Учтите, что llm‑d — не просто балансировщик. В precise‑режиме, как в кейсе выше, появляется control plane: реплики публикуют события о KV‑блоках, индексатор ведёт карту кэшей, а endpoint picker выбирает под. Есть и более лёгкий approximate‑режим, который оценивает расположение префиксов без событий от реплик. Оба режима добавляют поверхность для эксплуатации.

И про жизненный цикл подов. Rollout, рестарт, масштабирование и дренаж ноды дают холодный кэш и временный всплеск prefill и TTFT. Поэтому стратегия деплоя тоже входит в эксперимент: я выкатываю реплики по одной и смотрю, сколько времени hit rate возвращается к базовой линии.

Проверка: hit rate по кластеру близок к hit rate одной реплики под сопоставимой нагрузкой, очередь в пик около нуля, после rollout метрики возвращаются к норме за предсказуемое время.

Шаг 6. Квантуем через проверку качества

Риск: сэкономить память и потерять качество, которое заметит клиент, а не мониторинг.

В нашем сценарии первый кандидат — FP8 на H100. Для других GPU схему квантования нужно подбирать по поддерживаемому бэкенду и результатам сравнения. INT4 я рассматриваю как следующий шаг, когда нужно ещё сильнее сократить память.

Конкретный метод (AWQ, GPTQ или вариант под ваш бэкенд) выбирается по сравнению на целевой модели и нагрузке: качество зависит от модели, задачи, калибровочных данных и ядер.

Здесь я разделяю две проверки.

  1. Производительность меряю по протоколу из шага 1: латентность, пропускная способность, доля ошибок.

  2. Качество меряю отдельно, eval‑гейтом: одни и те же кейсы gold‑set, несколько прогонов на кейс, допустимое падение задано до эксперимента.

Интервал строится для разницы между моделями, а не для каждой модели отдельно.

# (Python) eval-гейт: FP8 не хуже BF16 больше, чем на заранее заданный порог
import random

MARGIN = -0.01   # пример: допустимое падение доли pass на 1 п.п.;
                 # порог — продуктовое решение, фиксируется до эксперимента

def pass_rate(runs):                 # runs: [True, False, ...] — прогоны одного кейса
    return sum(runs) / len(runs)

def paired_lower_bound(base, cand, n=2000, alpha=0.05):
    ids = sorted(base.keys() & cand.keys())                    # только общие кейсы
    deltas = {cid: pass_rate(cand[cid]) - pass_rate(base[cid]) for cid in ids}
    means = []
    for _ in range(n):
        sample = [deltas[random.choice(ids)] for _ in ids]     # ресэмплим кейсы
        means.append(sum(sample) / len(sample))
    means.sort()
    return means[int(alpha * n)]      # односторонняя нижняя граница 95%

lower = paired_lower_bound(bf16_results, fp8_results)          # 5 прогонов на кейс
if lower < MARGIN:
    raise SystemExit(f"FP8 не прошёл гейт: нижняя граница разницы {lower:+.1%}")
print(f"FP8 прошёл гейт: нижняя граница разницы {lower:+.1%}, порог {MARGIN:+.0%}")

Гейт проверяет комбинацию FP8 весов и FP8 KV, которую мы включили в шаге 3. Если он не пройден, следующий эксперимент их разделяет: отдельно FP8 веса с BF16 KV и наоборот.

Так видно, что именно даёт деградацию. Как собрать gold‑set и не обмануть себя одним прогоном, я подробно разбирал в статье «7 ошибок в оценке качества LLM‑систем в продакшене».

Проверка: гейт пройден, а стоимость 1M токенов при повторном замере ниже базовой линии из шага 1.

Если цель не достигнута: следующий рычаг по сигналу из метрик

Эти оптимизации нельзя включать безусловно: их эффект зависит от нагрузки. Поэтому я включаю их только по конкретному сигналу и по одной.

Сигнал после повторного замера

Рычаг

Как проверить

Пропускная способность в норме, но TTFT и латентность выше SLA

Больше Tensor Parallelism на реплику: по бенчмаркам автора из hh.ru TP даёт меньшую латентность

TTFT p95 и время на токен снизились, стоимость 1M токенов выросла не больше допустимого

Латентность в норме, но упираемся в RPS

Больше независимых реплик или DP‑развёртывание vLLM: по тем же бенчмаркам DP даёт кратно больше пропускной способности и RPS

Токенов в секунду на GPU больше, пиковая очередь около нуля

Длинные промпты, и prefill мешает генерации

Разделение prefill и decode по разным пулам GPU

Пропускная способность выросла; поддержка требует заметно больше внимания и инженеров

Мало одновременных пользователей и жёсткая латентность

Спекулятивный декодинг

Время на токен упало. С ростом конкурентности эффект пропадает: память уходит под draft‑модель вместо KV‑кэша

Где этот подход не сработает

Если трафик маленький и карта половину суток простаивает, никакая настройка не спасёт экономику: дешевле API провайдера или serverless‑инференс.

Если промпты уникальны и общего префикса нет, а ответы длинные, нагрузка упирается в decode. Prefix caching и роутинг по префиксу дадут мало, основной эффект придёт от шагов 2, 3 и 6.

На контекстах порядка 100K+ токенов резко растёт доля памяти под KV‑кэш: для нашей модели в FP8 это около 15 ГиБ на одну последовательность.

Такой контекст может поместиться, но дальше приходится выбирать между ограничением контекста, сжатием истории, переиспользованием префиксов и выгрузкой KV‑кэша в CPU или хранилище. Выбор зависит от нагрузки, и это отдельная большая тема.

И ещё про финтех. Там я бы не спешил с INT4 даже при пройденном гейте: цена одной ошибки в ответе про ставку по кредиту выше, чем экономия на картах.

Чек‑лист перед тем, как просить новые GPU

  • Есть недельная базовая линия: GPU‑часы на 1M токенов по сумме за интервал, TTFT p95, hit rate, пиковая очередь.

  • По фазовым метрикам понятно, во что упирается нагрузка: в prefill или в decode.

  • KV‑кэш посчитан на реальной длине последовательности, а не на окне модели.

  • max-model-len соответствует контракту API; параметры планировщика подобраны нагрузочным тестом.

  • Начало промпта даёт одинаковые токены после chat template, включая tools; ранжирование RAG сохранено.

  • Общий кэш не пересекает границу доверия: cache_salt задан по тенанту или пользователю.

  • При нескольких репликах есть привязка сессии через доверенный заголовок, а при необходимости — роутинг по префиксу.

  • Rollout и масштабирование учтены в эксперименте.

  • FP8 весов и KV‑кэша прошёл парный eval‑гейт; масштабы FP8 KV проверены.

  • Повторный замер проведён по тому же протоколу и на той же топологии, следующий рычаг выбран по сигналу из метрик.

Главное, что стоит вынести из статьи: в нагрузке с повторяющимися префиксами значительная часть работы GPU может уходить на повторный prefill.

Поэтому сначала стоит проверить, работает ли переиспользование кэша, и только потом оценивать, сколько карт действительно нужно.

Источники

Когда стоимость инференса растёт, а качество и скорость моделей становятся сложнее контролировать, важно понимать, где именно система теряет ресурсы. Разобраться с экспериментами, автоматизацией ML‑процессов и контролем моделей в продакшене — следующий шаг после оптимизации инфраструктуры.

На открытых уроках мы разберём практические подходы к работе с ML‑проектами:

  • 29 сентября в 20:00. «MLFlow — контроль над ML‑экспериментами». Записаться

  • 15 октября в 18:00. «Автоматизация ML‑экспериментов с помощью GitLab CI/CD и CML». Записаться

  • 26 октября в 20:00. «Data Drift в машинном обучении: почему модели деградируют в продакшене и как это контролировать». Записаться

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.