- BrainTools - https://www.braintools.ru -

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

Материал подготовлен в рамках курса «MLOps» [1].

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

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

Сначала о том, почему разговор начинается с памяти [2]. В статье 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‑системами. Пройдите короткий бесплатный тест [3]по 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 = "nn".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}nnВопрос: {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

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

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

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

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

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

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

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

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

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

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

Чек‑лист перед тем, как просить новые 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.

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

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

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

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

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

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

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

Автор: sproshchaev

Источник [15]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/36147

URLs in this post:

[1] курса «MLOps»: https://otus.pw/PKLs/

[2] памяти: http://www.braintools.ru/article/4140

[3] короткий бесплатный тест : https://otus.pw/qUU5/

[4] внимания: http://www.braintools.ru/article/7595

[5] ошибки: http://www.braintools.ru/article/4192

[6] Статья MLOps‑инженера из hh.ru об эффективности инференса: https://habr.com/ru/companies/hh/articles/1062318/

[7] Разбор Cloud.ru: память H100 и KV‑кэш Llama 3 70B: https://habr.com/ru/companies/cloud_ru/articles/1050512/

[8] Кейс: prefix‑cache routing с llm‑d на AWS EKS: https://dev.to/andygolubev/how-llm-d-prefix-cache-routing-made-qwen-7b-on-eks-23x-faster-2h8j

[9] Character.AI: Optimizing AI Inference: https://blog.character.ai/optimizing-ai-inference-at-character-ai-2/

[10] «7 ошибок в оценке качества LLM‑систем в продакшене»: https://habr.com/ru/companies/otus/articles/1067748/

[11] Записаться : https://otus.pw/IQjM/

[12] Записаться : https://otus.pw/Qimw/

[13] обучении: http://www.braintools.ru/article/5125

[14] Записаться: https://otus.pw/PVUG4/

[15] Источник: https://habr.com/ru/companies/otus/articles/1077956/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1077956

www.BrainTools.ru

Rambler's Top100