автор команда XIMTRX

Кейс: vLLM-кластер в 3 регионах, -60% cost/token

Анонимизированный кейс: LLM-стартап, 4 месяца на инференс-инфраструктуре. -60% cost/token, p99 latency стабилен. И 36-часовая охота за пропавшими 30% throughput.

#case-study #ai #llm #vllm #gpu

Этот пост: анонимизированный кейс, который мы вели весной и в начале лета 2025-го. Имя клиента и название модели мы не раскрываем по NDA, но цифры и техническая хронология реальные, с разрешения клиента. Конкретный момент, ради которого мы это вообще пишем: 36-часовая охота за 30 процентами throughput, которые «должны были быть» по спецификации железа и не было. То, как мы его нашли, теперь висит в нашем week-1 чеклисте для любого multi-GPU хоста.

Setup

Клиент: YC-бэкнутый LLM-стартап, восемь инженеров, один зафайнтьюненный 70B-чекпойнт поверх открытой базы. На момент захода они жгли около 140 тысяч долларов в месяц на managed inference API одного из крупных вендоров, и эта цифра росла на 18-22 процента месяц к месяцу. Подняли seed, и часть раунда явно ушла на план «забрать инференс домой».

Железо собрали через двух bare-metal-вендоров в трёх регионах. Франкфурт и Ashburn: по 8×H100 PCIe (именно PCIe, не SXM, потому что HGX-шасси им не дали в нужном окне). Сингапур: 16×L40S, под более мелкие варианты модели и под спекулятивный декодинг. vLLM 0.4.x как основной сервер, Triton Inference Server как фолбэк под несколько legacy-эндпойнтов, которые они не хотели переписывать на старте.

Скоуп с нашей стороны: 4 месяца end-to-end. Поднять vLLM, развести трафик через geo-DNS, выкатить observability с cost-per-token-метрикой на каждый регион, autoscale по регионам с учётом spot/on-demand mix у вендоров. Никаких операций над клиентским кодом модели, только инференс-слой и всё, что вокруг.

Headline outcome через 4 месяца: cost/token упал на 60 процентов против их managed-API бейзлайна, p99 latency не отличался от того, что они платили API-вендору, throughput на хост вышел на 28 процентов выше, чем мы планировали в проектной смете (об этом ниже).

Week 1: hardware, kernel, vLLM up

Первая неделя прошла так, как и должна проходить первая неделя на bare-metal инференсе. К дню 2 у нас были подняты все три региона на уровне OS и Kubernetes. К дню 4 в Frankfurt-кластере крутился single-host vLLM на одной H100, и мы прогнали бенчмарк против опубликованных вендором цифр для этого профиля модели.

Single-host single-GPU результат: в пределах 4 процентов от вендор-эталона по tokens/sec, KV-cache eviction <1 процента, p95 first-token latency в пределах SLO. Это укладывалось в наши ожидания, и команда клиента выдохнула первый раз за раунд. Мы тоже выдохнули, но с оговоркой: full-host бенчмарк ещё впереди, и именно он показывает реальную capacity, а не один GPU из восьми.

Week 2: расширились до full multi-GPU

Дни 5-7 ушли на бринг-ап tensor-parallel конфигурации на все 8 H100 одного хоста, плюс настройка prefix caching, плюс корректная привязка NCCL к нужным интерфейсам.

День 8, утро: первый full-host бенчмарк, та же самая модель, тот же датасет промптов, что в single-GPU тесте, только теперь tensor-parallel=8. Ожидали ~7.2x scaling от single-GPU числа (реалистичная оценка, не наивные 8x). Получили 5.0x. То есть около 30 процентов throughput недосчитались относительно того, что должно было быть по спеке и по single-host single-GPU экстраполяции.

Это та точка, где кейс становится интересным. Бенчмарк-делта на 30 процентов в validation-фазе это не «скучный тюнинг», это что-то структурно неправильно. Мы открыли инцидент Sev-3 (внутренний триггер: расхождение с вендор-числами >10 процентов в validation), назначили двух инженеров на дежурный лид, и поехали по слоям.

Часы 0-8 (день 8 проекта): первые гипотезы

Стандартный набор подозреваемых, по убыванию вероятности:

  1. Версия vLLM. Проверили: 0.4.x current, релиз ноты на нашу комбинацию модели и параллелизма ничего проблемного не упоминают. Скипнули вперёд на одну минор-версию в dev-окружении: та же дельта. Гипотеза отпала.
  2. CUDA / NCCL. Версии совпадали с тем, что вендор H100 рекомендовал в их reference-конфиге под PCIe-шасси. NCCL_DEBUG=INFO показал, что коллективы поднимаются на ожидаемых интерфейсах, без фолбэков на TCP.
  3. PCIe gen и lane width. nvidia-smi topo -m и lspci -vv подтвердили: gen5 x16 на всех восьми слотах, никакого throttle до gen4, никаких залинкованных x8. Это исключило самую простую физическую причину.
  4. Thermal throttling. Telemetry чистая: 75-78°C под нагрузкой, throttle-флаги в nvidia-smi -q пустые, fan-кривая в норме у вендора.
  5. KV-cache eviction. Метрики vLLM показывали eviction rate <2 процентов на полном нагрузочном профиле. Не объясняет 30-процентную дельту даже близко.

К часу 8 у нас был чистый список не-причин. Это полезно, но это не ответ.

Часы 8-24: углубились в профилирование

Раз поверхностные слои отпали, мы собрали per-layer латентность вручную. Обвязали каждый трансформер-блок парой torch.cuda.synchronize() плюс CUDA events до и после, прогнали 500 запросов через профилированную сборку, сложили распределения.

Картина прояснилась. Forward-проход внутри одного трансформер-блока укладывался в ожидания. А вот latency между блоками, та самая, которую съедает синхронизация tensor-parallel коллективов через NCCL, прыгала с типичных 2-3 µs на пару GPU до 12-18 µs на некоторых парах. Не на всех. На примерно половине.

Прикинули вклад: если сложить лишние 10-15 µs на каждый из 80 трансформер-блоков, на нескольких парах GPU из тех, что попадают в tensor-parallel группу, получается около 25 процентов потерянного throughput. То есть мы наконец нашли где течёт, оставалось понять почему.

Гипотеза на этом этапе: что-то в физической топологии хоста делает peer-to-peer передачу между «плохими» парами GPU дороже, чем между «хорошими». В теории все 8 H100 PCIe равноправны на одной материнке, на практике хост этого класса почти всегда двухпроцессорный.

Час 28: правильная команда

Ключевая команда, которую мы должны были прогнать в день 1: nvidia-smi topo -p2p w. Расширенный вариант nvidia-smi topo -m, который показывает не просто PCIe-топологию, а реальный peer-to-peer write capability между каждой парой GPU.

Вывод оказался однозначный: PIX (PCIe Switch direct, дешёвая прямая дорожка) только внутри NUMA-группы каждого сокета. Cross-NUMA пары рапортовали SYS, что означает «трафик уходит через CPU root complex и далее по UPI-линку между сокетами». Тот самый UPI-линк, который для GPU peer-to-peer трафика заметно дороже по latency, чем прямая PCIe Switch дорожка.

Самое неприятное: вывод этой команды лежал в наших setup-логах с дня 1. Мы её запускали как часть стандартного hardware-фингерпринта. Никто на него не посмотрел внимательно, потому что single-host single-GPU бенчмарк «выглядел нормально» в агрегате, и команда переключилась на бринг-ап остальных регионов параллельно.

Это та самая ошибка, ради которой стоит писать кейс. Данные были, никто не прочёл.

Часы 28-36: фикс

Три шага в одной последовательности, каждый отдельной правкой, чтобы можно было откатить, если что-то сломается.

Час 28-30: NUMA-pinning vLLM-воркеров. Обернули запуск vLLM в numactl --cpunodebind=N --membind=N так, чтобы воркер, обслуживающий GPU 0-3, сидел на CPU и памяти NUMA-node 0, а воркер на GPU 4-7 на node 1. Это решает половину проблемы: убирает cross-NUMA память, но ещё не убирает cross-NUMA P2P между GPU, если они попали в одну tensor-parallel группу.

Час 30-33: пересборка vLLM-юнита с правильным CUDA_VISIBLE_DEVICES. Дефолтная конфигурация запускала один tensor-parallel=8 процесс, который жадно брал все 8 GPU и не имел понятия о NUMA-границах. Мы переразложили: два процесса tensor-parallel=4, каждый видит ровно те 4 GPU, что сидят на своём NUMA-узле. На уровне сервиса добавили роутинг запросов между двумя процессами на одной машине, vLLM это поддерживает штатно через свой scheduler.

Час 33-36: Kubernetes node affinity и pod anti-affinity. Чтобы это не сломалось при следующем рестарте или скейлинге, прописали правила: tensor-parallel поды получают NUMA-local GPU группы через device plugin с topology hints, и cross-NUMA tensor-parallel конфигурации просто запрещены на уровне scheduler.

Час 36: throughput +28%

После всех трёх шагов прогнали тот же full-host бенчмарк. Scaling вышел на 7.4x от single-GPU числа, то есть на 28 процентов выше прежних 5.0x. Cost/token при пересчёте под фактический throughput упал ещё на 12 процентов сверху уже полученной экономии от ухода с managed API.

End-to-end окно инцидента: примерно 36 часов от первого фейл-бенчмарка до зафиксированной production-конфигурации. Никакого клиентского трафика к этому моменту через хосты ещё не шло, на то и есть validation-неделя.

Что попало в раннбук

Четыре записи, добавлены в наш внутренний week-1 чеклист и в шаблоны проектных раннбуков.

  1. NUMA topology inspection обязательна. На каждом новом multi-GPU хосте в день 1 прогоняется nvidia-smi topo -m, nvidia-smi topo -p2p w, lscpu и numactl --hardware, и вывод линкуется в проектный раннбук. Не просто прогоняется, а явно читается человеком и аннотируется: «есть cross-NUMA GPU-пары: да/нет, какие».
  2. Бенчмарк против вендор-чисел это Sev-3 триггер, если дельта >10 процентов. Раньше это было неформальным «обсудим, если выглядит странно». Теперь это явный порог, на котором открывается инцидент, назначается owner и идёт по нашему стандартному incident-флоу. MTTA на validation-инциденты такого класса у нас сейчас 12-18 минут.
  3. Tensor-parallel placement декларируется явно. Никаких «vLLM сам разберётся» на split-NUMA хостах. В конфиге сервиса прописываются NUMA-bound группы GPU, и Kubernetes scheduler знает про topology через device plugin с topology hints.
  4. Observability sidecar репортит numa_misses. Дополнительная метрика из numastat на каждой ноде, экспортируется в Prometheus, алерт срабатывает, если процент miss-ов на инференс-воркере уходит выше базового профиля.

Урок

Big-iron defaults предполагают single-NUMA хост. На двухсокетной машине с GPU, развешенными по разным root complex, фреймворк не пытается угадать вашу топологию: он берёт удобную конфигурацию по умолчанию (один большой tensor-parallel процесс на все видимые GPU), и эта конфигурация тихо платит cross-NUMA пенальти на каждом коллективе.

Это не баг vLLM и не баг H100 PCIe. Это место, где автоматики нет, и инженер должен объявить намерение явно.

Второй урок, более общий: фраза «в агрегате выглядит нормально» это самое дорогое предложение в инференс-операциях. Single-GPU бенчмарк выглядел нормально. Setup-логи выглядели нормально. Thermal-картина выглядела нормально. Дельта была видна только в одной конкретной команде, прогнанной в день 1, и в per-layer профилировании, прогнанном на 28-м часе инцидента. Всё остальное было шумом.

И третий, самый важный для нас как для подрядчика: эта история произошла на validation-неделе, не в продакшене. Никакого клиентского трафика, никакого SLA-окна, никаких писем в полночь. Validation-фаза существует именно для того, чтобы такие истории случались на ней, а не на live-инференсе с реальной нагрузкой. Когда мы продаём 4 месяца на стенд-ап инференс-кластера, эта неделя в смете не для красоты.

Команда XIMTRX

← Все статьи