Привет, Хабр! На связи снова Данила Гудынин, DevOps-инженер направления Evolution ML Inference в Cloud.ru.

В прошлой статье про снижение стоимости инференса мы пробежались по теории того, что и на каких уровнях стека можно подкрутить, чтобы повысить утилизацию своих GPU. Там вы могли определиться, какой подход в принципе подходит для ваших задач.

В этой части мы нырнем в практически-технический хар дкор и расскажем, как оптимизировать работу своих графических процессоров с KServe ModelMesh или vLLM Production Stack, подсветим, где разбросаны грабли в этом деле, а еще заглянем под капот к Cloud.ru Shared GPU и объясним, как именно он позволяет нам ставить цены на уровне западных облаков при кратно более дорогом железе в РФ.

ML-инженеры, DevOps и MLOps-архитекторы, можете сразу добавлять в закладки, чтобы возвращаться и списывать нужные конфиги. Наливайте бочку чая или чего покрепче, постарался изложить все сугубо по делу, много кода спрятал в «раскрывашки», так что не пугайтесь обозначенного выше времени чтения.

Содержание статьи

В предыдущей части мы с вами выяснили, что существует два стула подхода к обслуживанию моделей: самурай SMS и ниндзя MMS. Первый живет свою лучшую расточительную жизнь, принося все ресурсы GPU на алтарь своего сюзерена. Второй экономит на жилье, но зато трудолюбивый и не жалеет своих сил на благо нескольких моделек, впрочем, и надежностью не отличается. Также мы выяснили, что ни один из этих воинов нас не устраивает и нужно какое-то гибридное решение. Одним из кандидатов на гибрид стал KServe ModelMesh. Давайте посмотрим внимательнее, что это вообще за зверь такой.

Погружаемся в архитектуру ModelMesh, выясняем, что с ней не так

ModelMesh представляет собой сложную многоуровневую систему, которая радикально меняет подход к обслуживанию моделей. В ее основе лежит интеллектуальный роутер, который берет на себя все решения о том, где и когда запускать модели, освобождая разработчиков от необходимости думать об инфраструктуре.

Архитектурно система состоит из трех ключевых слоев:

1. Слой роутинга и кеширования. ModelMesh Router работает как диспетчер в аэропорту: анализирует входящие запросы, проверяет состояние кеша и принимает решение о том, на какой inference-сервер направить задачу. Используется распределенный LRU-кеш, который автоматически загружает «горячие» модели в память и выгружает редко используемые. Вытеснение из памяти происходит не просто по принципу «давно не обращались», оно учитывает время, частоту, размер модели и географическую близость для оптимального распределения ресурсов по кластеру. Кроме того, приоритетные модели можно всегда защитить от вытеснения.

2. Слой исполнения. Под капотом работают стандартные serving runtime: MLServer, Triton, TorchServe, которые непосредственно обрабатывают запросы к моделям. ModelMesh не изобретает велосипед, а использует проверенные решения, добавляя поверх них слой управления.

Пример конфигурации Triton runtime
apiVersion: serving.kserve.io/v1alpha1
kind: ServingRuntime
metadata:
  name: triton-runtime
spec:
  containers:
  - name: triton
    image: nvcr.io/nvidia/tritonserver:22.12-py3
    args:
      - tritonserver
      - --model-store=/models
      - --grpc-port=8001
      - --http-port=8000
      - --metrics-port=8002
    resources:
      limits:
        cpu: 4
        memory: 8Gi
        nvidia.com/gpu: 1
      requests:
        cpu: 2
        memory: 4Gi
  multiModel: true
  grpcEndpoint: port:8001
  supportedModelFormats:
    - name: tensorflow
      version: "2"
    - name: pytorch
      version: "1"
    - name: onnx
      version: "1"

3. Слой хранения и интеграции. Storage Puller автоматически загружает модели из различных источников (S3, MinIO, PVC, HTTP), кеширует их локально и управляет очисткой неиспользуемых артефактов.

Давайте разберем компоненты детально:

  1. ModelMesh Controller — центральный мозг системы
    — управляет жизненным циклом InferenceService;
    — принимает решения о размещении моделей;
    — мониторит состояние всех подов и моделей;
    — реагирует на изменения в кластере.

  2. ModelMesh Serving Pods — рабочие узлы
    — каждый содержит несколько контейнеров;
    — могут одновременно обслуживать множество моделей;
    — автоматически масштабируются в зависимости от нагрузки.

  3. Storage Integration — система хранения моделей
    — поддерживает S3, MinIO, PVC, HTTP;
    — отвечает за автоматическую загрузку и кеширование моделей;
    — обеспечивает версионирование и управление артефактами.

Анатомия пода Model Mesh
Анатомия пода Model Mesh

Звучит все это прекрасно, здорово и технологично. Так что же не так с этим решением, почему мы его попробовали и отказались?

Дело в том, что ModelMesh изначально родился в недрах корпоративного монстра IBM как инструмент для Watson — того самого, который обещал заменить всех аналитиков (спойлер: не заменил). Видя потенциал технологии, IBM приняла стратегическое решение передать ModelMesh под эгиду проекта KServe и сделать его открытым исходным кодом. Шаг был логичный: сообщество получило доступ к обкатанной в продакшне технологии, а IBM могла развивать ее совместно с Kubernetes. Однако при переносе выяснилось: если что-то годами преспокойно работало в корпоративной среде с предсказуемыми нагрузками, совершенно не факт, что оно продолжит это делать в динамической облачной среде. Выяснилось, что инструмент демонстрирует серьезные архитектурные проблемы при:

  • больших нагрузках с тысячами одновременных запросов;

  • большом распределении разных моделей по подам;

  • динамическом масштабировании в cloud-native окружении.

А вот, собственно, корень проблемы:

Все дороги ведут в etcd
Все дороги ведут в etcd

Каждый pod постоянно синхронизируется с etcd, что создает:

  1. Высокую нагрузку на etcd при большом количестве подов.

  2. Задержки в обновлении состояния моделей.

  3. Проблемы с консистентностью при частых изменениях.

  4. Ограничения масштабируемости системы в целом.

В принципе, если вы не коммерческий провайдер, и вам не нужны сверхвысокие SLA, нет жестких требований к изоляции между моделями и модельки размером больше 10 ГБ вы не обрабатываете, решением пользоваться вполне можно. Теперь рассказываем, как.

Практический пример развертывания

Давайте рассмотрим полный пример развертывания ModelMesh для обслуживания нескольких моделей:

1. Устанавливаем ModelMesh в K8s
# Клонируем репозиторий
git clone -b release-0.10 https://github.com/kserve/modelmesh-serving.git
cd modelmesh-serving

# Установка в namespace-scoped режиме
./scripts/install.sh --namespace modelmesh-serving --namespace-scope-mode --quickstart
2. Настраиваем хранилище
apiVersion: v1
kind: Secret
metadata:
  name: storage-config
stringData:
  minio: |
    {
      "type": "s3",
      "access_key_id": "minio",
      "secret_access_key": "minio123",
      "endpoint_url": "http://minio:9000",
      "default_bucket": "modelmesh-models",
      "region": "us-east-1"
    }
3. Создаем шаблон, по которому будет развертываться модель (serving runtime)
apiVersion: serving.kserve.io/v1alpha1
kind: ServingRuntime
metadata:
  name: custom-sklearn-runtime
spec:
  containers:
  - name: mlserver
    image: seldonio/mlserver:1.3.2
    env:
      - name: MLSERVER_MODELS_DIR
        value: "/models"
      - name: MLSERVER_LOAD_MODELS_AT_STARTUP
        value: "false"
    resources:
      requests:
        cpu: 500m
        memory: 1Gi
  multiModel: true
  grpcEndpoint: port:8001
  supportedModelFormats:
    - name: sklearn
      version: "1"
4. А теперь описываем, что, собственно, надо развернуть
# Модель классификации текста
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
  name: text-classifier
  annotations:
    serving.kserve.io/deploymentMode: ModelMesh
spec:
  predictor:
    model:
      modelFormat:
        name: sklearn
      storage:
        key: minio
        path: models/text-classifier/model.joblib

---
# Модель рекомендаций
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
  name: recommendation-engine
  annotations:
    serving.kserve.io/deploymentMode: ModelMesh
spec:
  predictor:
    model:
      modelFormat:
        name: sklearn
      storage:
        key: minio
        path: models/recommendations/model.joblib

---
# Модель детекции аномалий
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
  name: anomaly-detector
  annotations:
    serving.kserve.io/deploymentMode: ModelMesh
spec:
  predictor:
    model:
      modelFormat:
        name: sklearn
      storage:
        key: minio
        path: models/anomaly/model.joblib

Готово, модели развернуты. Но ведь теперь надо убедиться, что все работает как надо? К счастью, ModelMesh предоставляет богатые возможности мониторинга через Prometheus-метрики, например:

  • modelmesh_models_loaded_total — количество загруженных моделей;

  • modelmesh_model_loading_duration_seconds — время загрузки моделей;

  • modelmesh_inference_request_duration_seconds — время выполнения инференса;

  • modelmesh_cache_miss_total — количество промахов кеша;

  • modelmesh_memory_usage_bytes — использование памяти.

А еще можно сделать Grafana-дашборд
{
  "dashboard": {
    "title": "ModelMesh Performance",
    "panels": [
      {
        "title": "Models per Pod",
        "type": "stat",
        "targets": [
          {
            "expr": "sum by (pod) (modelmesh_models_loaded_total)"
          }
        ]
      },
      {
        "title": "Cache Hit Rate",
        "type": "stat",
        "targets": [
          {
            "expr": "rate(modelmesh_cache_hit_total[5m]) / rate(modelmesh_inference_requests_total[5m]) * 100"
          }
        ]
      }
    ]
  }
}

Ограничения и компромиссы

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

  • Задержки при холодном старте. Первый запрос к модели может быть очень медленным (что не удивительно, если она еще не успела осесть в кеше).

  • Дополнительные затраты памяти. Система управления моделями требует выделения дополнительной памяти для своих внутренних нужд — хранения метаданных, кешей, управления жизненным циклом. Это уменьшает объем памяти, доступный для моделей.

  • Сложность поиска и устранения ошибок. При возникновении проблем очень сложно их локализовать и понять: что-то пошло не так в самой модели, в системе распределения ресурсов или нагрузка на инфраструктуру оказалась несоответствующей? Особенно трудно отследить ошибки, если модели обслуживаются в одном пуле с разными приложениями.

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

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

  • Версионирование. Необходимо отслеживать изменения в моделях, чтобы можно было быстро откатить обновление при ошибках, провести A/B-тестирование или анализ. Это включает версионирование веса моделей, конфигураций и данных обучения. При частых обновлениях моделей возникает сложность поддерживать актуальность и согласованность версий.

  • Мультитенантность. Ограниченная изоляция между клиентами приводит к ситуации, когда проблемы одного пользователя могут повлиять на работу другого, например из-за общих ресурсов памяти или вычислительных блоков. Это создает риски для безопасности и стабильности работы в условиях многоклиентского использования.

vLLM Production Stack: современный подход к инференсу

Если ModelMesh управляет всем циклом жизни запускаемых моделей централизованно, организуя все и решая, где исполнять, то vLLM Production Stack действует иначе. Он работает как распределитель запросов, который находится перед группой vLLM-инстансов, а логика планирования при этом у каждого экземпляра vLLM остается своя. В российском сегменте интернета про vLLM написано маловато, поэтому постараемся исправить эту досадную несправедливость.

Архитектура vLLM Production Stack
Архитектура vLLM Production Stack

Ключевые компоненты системы

Центральный компонент системы — Request Router, который работает как интеллектуальный шлюз перед vLLM инстансами. Его основные функции:

1. Алгоритмы маршрутизации:

  • Round-Robin — циклическое распределение запросов

  • Session-ID based — маршрутизация по идентификатору сессии

  • Prefix-aware — учет префиксов для максимизации KV-кеша

  • KV cache-aware — интеллектуальная маршрутизация на основе состояния кеша

2. Хеширование для балансировки нагрузки:

# Пример алгоритма хеширования для session-based routing
def get_target_instance(session_id: str, available_instances: List[str]) -> str:
    """
    Консистентное хеширование для определения целевого инстанса
    """
    hash_value = hashlib.md5(session_id.encode()).hexdigest()
    instance_index = int(hash_value, 16) % len(available_instances)
    return available_instances[instance_index]

3. Service Discovery:

  • автоматическое обнаружение vLLM подов через Kubernetes API,

  • health checking и автоматическое исключение неработающих инстансов,

  • dynamic backend registration.

Пример конфигурации роутера:

apiVersion: production-stack.vllm.ai/v1alpha1
kind: StaticRoute
metadata:
  name: llama-routing
spec:
  serviceDiscovery: kubernetes
  routingLogic: prefix-aware
  backends:
    - service: vllm-llama-service-1
      weight: 100
    - service: vllm-llama-service-2  
      weight: 100
  healthCheck:
    enabled: true
    interval: 30s
    timeout: 5s

Serving Engine с оптимизацией vLLM

Поскольку в vLLM Production Stack у каждого инстанса есть возможность работать со своим нативным шедулером, это дает нам несколько важных преимуществ:

  • полная совместимость с upstream vLLM,

  • все оптимизации vLLM работают без изменений,

  • сontinuous batching остается эффективным,

  • поддержка всех vLLM-фич (Speculative Decoding, Multi-LoRA и т.д.).

servingEngineSpec:
  modelSpec:
  - name: "llama-3-70b"
    repository: "vllm/vllm-openai"
    tag: "v0.4.0"
    modelURL: "meta-llama/Llama-3-70b-chat-hf"
    
    # Включаем LoRA поддержку
    enableLoRA: true
    maxLoraRank: 16
    
    # vLLM специфичные настройки
    vllmConfig:
      tensorParallelSize: 4
      maxModelLen: 4096  
      enforceEager: false
      gpuMemoryUtilization: 0.9
      enableChunkedPrefill: true
      
    # Kubernetes resources
    replicaCount: 2
    requestGPU: 4
    requestMemory: "200Gi"

Чтобы наблюдать за производительностью, у vLLM есть встроенная система мониторинга, поэтому с помощью Prometheus можно отследить любые метрики, например:

  • vllm_request_duration_seconds — время обработки запросов;

  • vllm_time_to_first_token_seconds — TTFT-метрики;

  • vllm_cache_hit_rate — эффективность KV кеша;

  • vllm_memory_usage_bytes — использование GPU-памяти;

  • router_request_count_total — количество запросов через роутер.

Для полного счастья и прозрачности создаем Grafana-дашборд:

{
  "dashboard": {
    "title": "vLLM Production Stack",
    "panels": [
      {
        "title": "Request Latency P95",
        "targets": [{
          "expr": "histogram_quantile(0.95, rate(vllm_request_duration_seconds_bucket[5m]))"
        }]
      },
      {
        "title": "Cache Hit Rate",
        "targets": [{
          "expr": "rate(vllm_cache_hit_total[5m]) / rate(vllm_inference_requests_total[5m]) * 100"
        }]
      }
    ]
  }
}

Под капотом у Request Router

Теперь, когда нам более-менее понятна вся цепочка процессов в vLLM, вернемся к ключевому компоненту — Request Router и посмотрим, как он анализирует поступающие запросы, принимает решения о том, на какой из инстансов направить запросы и динамически управляет дополнительными LoRA-адаптерами для персонализации и оптимизации моделей без перезагрузки сервисов. Если интересно, разворачивайте, если нет — скачем дальше.

1. Round-Robin роутинг

Round-Robin помогает быстро и просто равномерно делить нагрузку без сложных вычислений. Этот метод последовательно отправляет каждый новый запрос на следующий сервер из списка, циклично. Примерно как крупье, раздающий по очереди карты всем участникам игры. Такой подход прост и обеспечивает равномерное распределение нагрузки при условии, что все серверы примерно одинаковы по мощности.

class RoundRobinRouter:
    def __init__(self, backends: List[str]):
        self.backends = backends
        self.current_index = 0
        
    def get_next_backend(self) -> str:
        backend = self.backends[self.current_index]
        self.current_index = (self.current_index + 1) % len(self.backends)
        return backend
2. Session-ID роутинг

Этот метод гарантирует, что все запросы от одного пользователя или сессии попадут на один и тот же сервер. Это важно для улучшения производительности, потому что сервер сохраняет состояние или кеш, что ускоряет обработку последующих запросов. Важно, чтобы пользователь не «прыгал» между разными серверами, что могло бы ухудшить качество работы.

class SessionRouter:
    def route_request(self, request: Request) -> str:
        session_id = request.headers.get('session-id', 
                                       request.headers.get('x-session-id'))
        if session_id:
            return self.get_target_instance(session_id, self.backends)
        return self.fallback_router.route(request)
3. Prefix-aware роутинг

Представьте, что у вас есть список экспертов (серверов) и вопросы, которые им адресуют. Вы получаете вопрос из зала «С каким счетом Бразилия выиграла у Германии на ЧМ по футболу в 2014 году?». Логично будет, направить этот вопрос Саньку, который уже отвечал вам на околофутбольные запросы, а не Марине, которая отвечала вам, кто такой Серкан Болат и в чью дверь он стучится. Здесь суть та же: система анализирует префиксы входящих запросов и направляет их на инстансы, где уже есть похожие префиксы в KV-кеше.

Пример конфигурации Kubernetes для prefix-aware роутинга:

apiVersion: production-stack.vllm.ai/v1alpha1
kind: Router
metadata:
  name: prefix-aware-router
spec:
  routingStrategy: prefix-aware
  prefixConfig:
    minPrefixLength: 10
    maxPrefixLength: 512
    similarityThreshold: 0.8
    cacheStateUpdateInterval: 1s

Алгоритм вычисления наилучшего совпадения по префиксам:

def find_best_instance_for_prefix(prefix: str, instances: List[Instance]) -> Instance:
    best_match = None
    best_score = 0.0
    
    for instance in instances:
        # Получаем состояние KV кеша инстанса
        cache_prefixes = instance.get_cached_prefixes()
        
        for cached_prefix in cache_prefixes:
            similarity = calculate_prefix_similarity(prefix, cached_prefix)
            if similarity > best_score and similarity > self.similarity_threshold:
                best_score = similarity
                best_match = instance
                
    return best_match or self.fallback_instance()
4. KV cache-aware роутинг

При использовании этого метода система отслеживает состояние KV-кеша каждого инстанса, производит оценку «выгоды» от направления запроса в тот или иной инстанс на основе метрик кеша и текущей загрузки и отправляет запрос туда, где эффективность переиспользования кеша максимальна.

Как выполняется мониторинг состояния кеша каждого инстанса:

class KVCacheMonitor:
    def __init__(self):
        self.cache_states = {}
        
    async def update_cache_state(self, instance_id: str):
        """Получаем состояние KV кеша от vLLM инстанса"""
        response = await self.get_cache_metrics(instance_id)
        self.cache_states[instance_id] = {
            'hit_rate': response['cache_hit_rate'],
            'utilization': response['cache_utilization'],
            'active_sequences': response['active_sequences'],
            'prefixes': response['cached_prefixes']
        }

А вот так выстраивается логика маршрутизации с учетом состояния кеша:

def route_with_cache_awareness(self, request: Request) -> str:
    request_prefix = self.extract_prefix(request.prompt)
    
    # Ищем инстанс с максимальным потенциалом переиспользования кеша
    best_instance = None
    best_score = 0.0
    
    for instance_id, cache_state in self.cache_states.items():
        score = self.calculate_cache_reuse_score(
            request_prefix, 
            cache_state['prefixes'],
            cache_state['utilization']
        )
        
        if score > best_score:
            best_score = score
            best_instance = instance_id
            
    return best_instance or self.get_least_loaded_instance()

Динамическая загрузка LoRA-адаптеров

Одна из самых крутых функций vLLM Production Stack — возможность динамически загружать и выгружать LoRA-адаптеры без перезапуска подов. Если вы, конечно, старовер и еще используете LoRA-адаптеры (мы не осуждаем).

Поработаю немножко кэпом и расскажу, почему это важно. LoRA — это небольшая надстройка, которая добавляется к большой предварительно обученной модели и меняет ее поведение, не перекраивая всю модель. Адаптеры модульны, поэтому каждым из них можно управлять отдельно и хранить несколько для разных задач в файловой системе или хранилище данных. Если «включать» и «выключать» нужные адаптеры мы получаем:

  • бесперебойный инференс и доступ к большой модели без даунтайма;

  • эффективное использование ресурсов, ведь загружаются только те адаптеры, которые нужны, а остальные не давят на GPU и экономят память;

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

  • вместо того чтобы держать у себя 2 больших версии модели, можно держать одну и переключаться между разными адаптерами для A/B-тестирования, что опять-таки экономит ресурсы.

В общем, это нам надо, берем. API для управления LoRA
# Загрузка LoRA адаптера
curl -X POST http://vllm-instance:8000/v1/load_lora_adapter \
  -H "Content-Type: application/json" \
  -d '{
    "lora_name": "financial_advisor",
    "lora_path": "/data/lora-adapters/finance-lora",
    "max_lora_rank": 16
  }'

# Использование LoRA в запросе
curl -X POST http://router:8080/v1/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "meta-llama/Llama-3-70b-chat-hf",
    "prompt": "Analyze this financial report:",
    "lora_adapter": "financial_advisor",
    "max_tokens": 500
  }'

# Выгрузка LoRA адаптера  
curl -X POST http://vllm-instance:8000/v1/unload_lora_adapter \
  -H "Content-Type: application/json" \
  -d '{"lora_name": "financial_advisor"}'
Пример развертывания с LoRA-поддержкой
servingEngineSpec:
  modelSpec:
  - name: "llama-3-with-lora"
    modelURL: "meta-llama/Llama-3-8B-Instruct"
    
    # Включаем LoRA support
    enableLoRA: true
    maxLoraRank: 64
    loraModules: 128
    maxCpuLoras: 4
    
    # Переменные окружения для runtime LoRA
    env:
    - name: VLLM_ALLOW_RUNTIME_LORA_UPDATING
      value: "True"
    - name: VLLM_LORA_CACHE_SIZE
      value: "1024"
      
    # Подключаем volume с LoRA адаптерами
    volumeMounts:
    - name: lora-storage
      mountPath: "/data/lora-adapters"
      
  volumes:
  - name: lora-storage
    persistentVolumeClaim:
      claimName: lora-storage-pvc

Еще управлять LoRA можно декларативно через Kubernetes CRD. Таким образом мы говорим нашему кластеру, что вот такие LoRA-адаптеры нужны системе, а кубер сам обеспечит их корректное состояние, загрузку и управление.

Пример определения
apiVersion: production-stack.vllm.ai/v1alpha1
kind: LoRAAdapter
metadata:
  name: financial-advisor-lora
spec:
  name: "financial_advisor"
  source:
    type: "huggingface"
    repository: "your-org/financial-advisor-lora"
    revision: "main"
  target:
    modelRef: "llama-3-with-lora"
    instances: ["all"]  # или конкретные инстансы
  config:
    rank: 16
    alpha: 32
    modules: ["q_proj", "v_proj", "o_proj"]
  autoLoad: true
status:
  phase: "Ready"
  loadedInstances: ["vllm-instance-1", "vllm-instance-2"]
  lastUpdated: "2025-01-22T10:30:00Z"
Использование LoRA через роутер
apiVersion: production-stack.vllm.ai/v1alpha1
kind: RouteRule
metadata:
  name: financial-queries-route
spec:
  matcher:
    headers:
      "x-domain": "finance"
  target:
    lora: "financial_advisor"
    weight: 100
Пример сценария A/B тестирования LoRA адаптеров
apiVersion: production-stack.vllm.ai/v1alpha1
kind: RouteRule
metadata:
  name: lora-ab-test
spec:
  matcher:
    headers:
      "x-experiment": "lora-comparison"
  targets:
  - lora: "financial_advisor_v1"
    weight: 50
  - lora: "financial_advisor_v2" 
    weight: 50
Доменно-специфичная маршрутизация через классификатор доменов
# Автоматическое определение домена и применение соответствующего LoRA
class DomainAwareRouter:
    def __init__(self):
        self.domain_classifiers = {
            'finance': 'financial_advisor',
            'medical': 'medical_assistant', 
            'legal': 'legal_advisor',
            'code': 'code_assistant'
        }
        
    async def route_with_lora(self, request: Request) -> RouteResult:
        domain = await self.classify_domain(request.prompt)
        lora_adapter = self.domain_classifiers.get(domain)
        
        return RouteResult(
            instance=self.select_instance_with_lora(lora_adapter),
            lora_adapter=lora_adapter
        )
Динамическое масштабирование LoRA-адаптеров
apiVersion: production-stack.vllm.ai/v1alpha1
kind: LoRAScaler
metadata:
  name: adaptive-lora-scaling
spec:
  targetLoRA: "customer_support"
  metrics:
  - type: RequestRate
    threshold: 100  # requests/minute
  - type: QueueDepth  
    threshold: 10
  scalePolicy:
    scaleUp:
      instances: 2
      stabilizationWindow: 60s
    scaleDown:
      instances: 1
      stabilizationWindow: 300s

К слову, не только LoRA-адаптерами в vLLM Production Stack можно рулить через CRD.

Можно управлять, например, развертыванием модели
apiVersion: production-stack.vllm.ai/v1beta1
kind: ModelDeployment
metadata:
  name: llama-3-70b-deployment
spec:
  model:
    name: "meta-llama/Llama-3-70b-chat-hf"
    format: "huggingface"
    quantization: "fp16"
  
  scaling:
    replicas: 4
    autoScaling:
      enabled: true
      minReplicas: 2
      maxReplicas: 8
      metrics:
      - type: QPS
        targetValue: 50
      - type: QueueDepth
        targetValue: 5
        
  resources:
    gpu: 4
    memory: "200Gi"
    storage: "100Gi"
    
  vllm:
    tensorParallelSize: 4
    maxModelLen: 4096
    enableChunkedPrefill: true
    
status:
  phase: "Ready"
  availableReplicas: 4
  conditions:
  - type: Ready
    status: "True"
    lastTransitionTime: "2025-01-22T10:00:00Z"
Параметры маршрутизации тоже поддаются дрессировке
apiVersion: production-stack.vllm.ai/v1alpha1
kind: Router
metadata:
  name: intelligent-router
spec:
  strategy: "prefix-aware"
  
  backends:
  - name: "llama-3-70b"
    selector:
      matchLabels:
        model: "llama-3-70b"
    weight: 100
    
  rules:
  - matcher:
      prefixPattern: ".*coding.*"
    target:
      lora: "code_assistant"
  - matcher:
      sessionId: "persistent"
    target:
      strategy: "session-sticky"
      
  healthCheck:
    enabled: true
    interval: 30s
    
  observability:
    metrics:
      enabled: true
    tracing:
      enabled: true
      sampling: 0.1

Для автоматизации управления жизненным циклом компонентов в vLLM Production Stack система использует Kubernetes Operator pattern, что позволяет значительно снизить ручной труд и ошибки. Основных оператора, которые могут быть полезны в задачах оптимизации инференса три:

  • vLLM Operator. Автоматизирует развёртывание подов vLLM, следит за их состоянием, выполняет обновления без остановок (rollouts) и оптимизирует расход ресурсов на основе метрик.

  • Router Operator. Управляет динамическим обновлением маршрутов запросов, обнаруживает новые backend-сервисы (service discovery), обеспечивает проверку здоровья компонентов (health checking) и защиту от сбоев (circuit breaking), а также настраивает балансировку нагрузки (load balancing).

  • LoRA Operator. Автоматизирует загрузку и выгрузку LoRA адаптеров, отвечает за управление их жизненным циклом, поддерживает A/B тестирование различных конфигураций и мониторит производительность адаптеров, а также их масштабирование.

Практическое развертывание

Сценарий для минимальной конфигурации

Шаг 1: Устанавливаем vLLM Production Stack в K8s
# Добавляем Helm репозиторий
helm repo add vllm https://vllm-project.github.io/production-stack
helm repo update

# Устанавливаем с минимальной конфигурацией
helm install vllm-stack vllm/vllm-stack -f values-minimal.yaml

values-minimal.yaml:

servingEngineSpec:
  modelSpec:
  - name: "opt125m"
    repository: "vllm/vllm-openai"
    tag: "latest"
    modelURL: "facebook/opt-125m"
    
    replicaCount: 2
    
    requestCPU: 2
    requestMemory: "8Gi"
    requestGPU: 0.5
    
    vllmConfig:
      maxModelLen: 2048
      gpuMemoryUtilization: 0.8
      
router:
  enabled: true
  strategy: "round-robin"
  
observability:
  prometheus:
    enabled: true
  grafana:
    enabled: true
Шаг 2: Проверяем развертывание
# Проверяем статус подов
kubectl get pods -l app=vllm-stack

# Проверяем роутер
kubectl port-forward svc/vllm-router-service 8080:80

# Тестируем API
curl http://localhost:8080/v1/models

Сценарий для развертывания нескольких моделей

servingEngineSpec:
  modelSpec:
  # Модель для общих задач
  - name: "llama-3-8b"
    modelURL: "meta-llama/Llama-3-8B-Instruct"
    replicaCount: 3
    requestGPU: 1
    
  # Модель для кода
  - name: "codellama-7b"  
    modelURL: "codellama/CodeLlama-7b-Instruct-hf"
    replicaCount: 2
    requestGPU: 1
    
  # Модель для embeddings
  - name: "bge-large"
    modelURL: "BAAI/bge-large-en-v1.5"
    replicaCount: 4
    requestGPU: 0.5

router:
  strategy: "intelligent"
  rules:
  - matcher:
      promptPattern: ".*code.*|.*programming.*|.*function.*"
    target:
      model: "codellama-7b"
  - matcher:
      requestType: "embedding"
    target:
      model: "bge-large"
  - matcher:
      default: true
    target:
      model: "llama-3-8b"

Автоскалирование и оркестрация

Горизонтальное масштабирование
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: vllm-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: vllm-llama-deployment
  minReplicas: 2
  maxReplicas: 10
  metrics:
  - type: Pods
    pods:
      metric:
        name: vllm_queue_depth
      target:
        type: AverageValue
        averageValue: "5"
  - type: Pods
    pods:
      metric:
        name: vllm_gpu_utilization
      target:
        type: AverageValue
        averageValue: "80"
behavior:
  scaleUp:
    stabilizationWindowSeconds: 60
    policies:
    - type: Percent
      value: 50
      periodSeconds: 60
  scaleDown:
    stabilizationWindowSeconds: 300
    policies:
    - type: Percent
      value: 25
      periodSeconds: 60
Вертикальное автомасштабирование
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: vllm-vpa
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: vllm-llama-deployment
  updatePolicy:
    updateMode: "Auto"
  resourcePolicy:
    containerPolicies:
    - containerName: vllm
      maxAllowed:
        memory: 400Gi
        nvidia.com/gpu: 8
      minAllowed:
        memory: 50Gi
        nvidia.com/gpu: 1

Ограничения и компромиссы

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

  • Заточка под LLM. Решение полностью ориентировано на большие языковые модели. Можно, конечно, с небольшими оговорками прикрутить интеграцию с других ML-моделек, но нам принципиально хотелось более универсального решения.

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

  • Задержки и overhead. Первый запрос к модели может обрабатываться медленнее, так как модель и необходимые данные загружаются в память только при первом обращении. Это особенно заметно при динамической загрузке LoRA адаптеров. Дополнительные затраты CPU, памяти и сети для управления всем этим великолепием иногда становятся просто раздражающими.

  • Сложность настройки и сопровождения. Если у вас небольшой штат, то коммерческая польза от оптимизации GPU может быть сведена к минимуму из-за того, что требуются редкие квалифицированные спецы, которые смогут настроить оптимальные параметры кеширования, маршрутизации, автомасштабирования и вовремя реагировать на сбои и аномалии. Да, использование Kubernetes Operator сокращает ручной труд, но требует времени и ресурсов на разработку и поддержку операторов.

Что под капотом изобретенного нами велосипеда

Целенаправленно поискав «грабли» в готовых решениях, мы поняли, что ни одно из них нам не подходит. Задержки при первом старте в ModelMesh были просто катастрофическими. Реальный тест на наших данных показал 8+ секунд! Чтобы вы понимали, в B2B порог неприемлемого начинается уже после 2 секунд. vLLM Production Stack сильно ограничен по области применения. Мы протестировали совместимость технологии с другими типами моделей (а вдруг?), но результат оказался неутешительным:

test_results:
  llm_models: ✅  # Llama, GPT работают отлично
  embedding_models: ❌  # BGE, Sentence-BERT не поддерживаются
  classification_models: ❌  # BERT-based классификаторы проблематичны
  computer_vision: ❌  # ResNet, YOLO вообще не работают
  custom_models: ❌  # Собственные архитектуры невозможны
Наша реальность такова и больше никакова
Наша реальность такова и больше никакова

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

Архитектура Cloud.ru Shared GPU и в чем суть решения

Cloud.ru Shared GPU состоит из двух основных компонентов.

1. cloudru-vgpu-plugin — компонент DaemonSet:

  • развертывание происходит на каждом GPU-узле,

  • инжектирует перехватчик vGPU в контейнеры,

  • управляет жизненным циклом .so библиотеки,

  • обеспечивает изоляцию процессов.

2. cloudru-vgpu-scheduler — центральный планировщик:

  • представляет физический графический процессор как кластер ресурсов.

  • выполняет интеллектуальные обязанности.

  • мониторит использование и производительность.

  • принимает решения по размещению рабочей нагрузки.

А теперь самая главная идея. Вместо изменения ML-фреймворков мы перехватываем вызовы CUDA на уровне библиотек и виртуализируем память GPU. Для этого мы ставим между подами и GPU наш интерцептор.

Поясняю, все обращения к GPU идут через две библиотеки:

  1. libcuda.so.1 — основные CUDA операции.

  2. libnvml.so.1 — управление GPU (память, мониторинг).

Мы используем middleware-версии этих библиотек, которые:

  1. Получают запрос от приложения: «Дай мне информацию о GPU».

  2. Подменяют ответ: вместо «У тебя 80 ГБ памяти» отвечают «У тебя 10 ГБ памяти».

  3. Контролируют выделение: если приложение просит больше 10 ГБ — блокируют запрос.

  4. Передают разрешенные операции настоящим NVIDIA-библиотекам.

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

В итоге мы получаем:

  • Универсальность. Решение работает с любым ML-фреймворком (vLLM, Triton, TensorFlow, PyTorch), не боится проприетарного ПО, не требует обновлений от разработчиков фреймворков.

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

  • Изоляцию. Если приложение просит больше памяти — получает ошибку, но не крашит других, а каждый контейнер видит свою виртуальную GPU.

Теперь поговорим о технической реализации.

cloudru-vgpu-plugin: инженерный перехватчик

cloudru-vgpu-plugin развертывается как DaemonSet на каждом GPU-узле и выполняет ключевые функции:

1. Инжекция vGPU Interceptor
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: cloudru-vgpu-plugin
spec:
  template:
    spec:
      containers:
      - name: vgpu-plugin
        image: cloudru/vgpu-plugin:v1.2.0
        securityContext:
          privileged: true
        volumeMounts:
        - name: vgpu-lib
          mountPath: /usr/local/lib/vgpu
        - name: kubelet-pods
          mountPath: /var/lib/kubelet/pods
        env:
        - name: NODE_NAME
          valueFrom:
            fieldRef:
              fieldPath: spec.nodeName
2. Процесс инжекции перехватчика
// Pseudocode процесса инжекции
void inject_vgpu_interceptor(pid_t container_pid) {
    // 1. Получаем PID контейнера от kubelet
    container_info = get_container_info(container_pid);
    
    // 2. Монтируем .so библиотеку в namespace контейнера
    mount_vgpu_library(container_info.mount_namespace);
    
    // 3. Устанавливаем LD_PRELOAD для перехвата CUDA вызовов
    set_environment_variable(container_pid, "LD_PRELOAD", 
                           "/usr/local/lib/vgpu/libvgpu-interceptor.so");
    
    // 4. Передаем resource limits в interceptor
    set_gpu_memory_limit(container_pid, container_info.gpu_memory_limit);
}

cloudru-vgpu-scheduler: интеллектуальное распределение

cloudru-vgpu-scheduler представляет собой Kubernetes Deployment, который управляет GPU ресурсами как единым кластером, реализуя алгоритмы для решения «задачи о рюкзаке».

Практические сценарии применения

Сценарий 1: Production workload (binpack-стратегия)

Оптимально для стабильных продакшн-нагрузок.

Преимущества:

  • максимальная утилизация ресурсов,

  • меньше активных узлов = экономия энергии,

  • упрощенный мониторинг,

  • лучшее использование сетевых ресурсов,

Пример развертывания:

  • Всего узлов: 10

  • Активных узлов: 6 (остальные 4 в standby)

  • Утилизация GPU: 85%

  • Экономическая эффективность: высокая

Сценарий 2: Experimental workload (spread-стратегия)

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

Преимущества:

  • изоляция экспериментов,

  • снижение blast radius при сбоях,

  • параллельное выполнение задач,

  • лучшая отказоустойчивость.

Пример развертывания:

  • Всего узлов: 10

  • Активных узлов: 10 (все узлы активны)

  • Утилизация GPU: 60%

  • Отказоустойчивость: высокая

Гибридная стратегия для смешанных workload:

func (s *VGPUScheduler) calculateHybridScore(node *GPUNode, gpu *PhysicalGPU, request ResourceRequest) float64 {
    // Анализируем тип workload по labels
    workloadType := request.Pod.Labels["workload-type"]
    
    switch workloadType {
    case "production":
        return s.calculateGPUBinpackScore(gpu, request)
    case "experiment":
        return s.calculateGPUSpreadScore(gpu, request)
    case "batch":
        // Для batch заданий приоритет - доступность больших блоков памяти
        return s.calculateFragmentationScore(gpu, request)
    default:
        // Balanced approach по умолчанию
        binpackScore := s.calculateGPUBinpackScore(gpu, request)
        spreadScore := s.calculateGPUSpreadScore(gpu, request)
        return (binpackScore + spreadScore) / 2
    }
}

vGPU Interceptor: сердце системы

1. Архитектура интерцептора
// Основная структура interceptor'а
typedef struct {
    pid_t process_id;
    size_t memory_limit;
    size_t memory_used;
    int device_id;
    bool enforcement_enabled;
    
    // Статистика
    uint64_t total_allocations;
    uint64_t failed_allocations;
    uint64_t peak_memory_usage;
} vgpu_context_t;
2. Перехват CUDA-вызовов
// Перехват cudaMalloc
cudaError_t cudaMalloc(void **devPtr, size_t size) {
    vgpu_context_t* ctx = get_process_context();
    
    // Проверяем лимиты BEFORE allocation
    if (ctx->memory_used + size > ctx->memory_limit) {
        log_allocation_failure(ctx, size, ctx->memory_used, ctx->memory_limit);
        return cudaErrorMemoryAllocation;
    }
    
    // Вызываем оригинальную функцию
    cudaError_t result = REAL_CUDA_MALLOC(devPtr, size);
    
    if (result == cudaSuccess) {
        // Обновляем статистику
        ctx->memory_used += size;
        ctx->total_allocations++;
        
        // Отправляем метрики в plugin
        report_memory_usage(ctx->process_id, ctx->memory_used);
    }
    
    return result;
}

Результат и как это работает в продакшне

Если коротко — работает отлично. Утилизация GPU выросла с 23% до 78% (+339%), задержка увеличилась всего на 7% (подробные таблицы до/после можно посмотреть в предыдущей статье), зато отладка стала в пять раз быстрее. Затраты на GPU-инфраструктуру снизились на 67%, задержки держатся в допустимых пределах с коэффициентом вариации <12%. Спустя восемь месяцев после внедрения система управляет 247 моделями на 52 GPU-узлах, и за это время у нас было всего три инцидента.

Помимо отличных результатов, изобретение собственного Shared GPU принесло нам несколько важных уроков:

Урок 1: Чем проще — тем лучше

Попытки создать сложные системы управления моделями (вроде ModelMesh) часто создают больше проблем, чем решают. Зато старый добрый инженерный принцип KISS (keep it simple, stupid) работает безотказно.

Урок 2: Мониторинг — это база, а не опция

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

Урок 3: Система должна быть универсальной

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

Урок 4: Удобство разработчиков решает все

Если система неудобна для DS-инженеров и они тратят много времени на отладку и диагностику проблем, то они найдут способы обойти эту систему. Важно делать инструменты максимально дружелюбными для тех, кто их использует каждый день.

Вот, кажется, и все. Эй, кто осилил эту простыню вместе с «раскрывашками», вы там живы? Понимаю, объем негуманный, но уж лучше все сразу и в одном месте. Расскажите в комментах, что пробовали, чтобы выжать максимум из своих GPU, но так, чтобы они не надорвались, и какие у вас впечатления от этих решений?

Комментарии (0)