Нужна ли справедливая очередь GPU нескольким командам?
Справедливая очередь GPU помогает нескольким командам делить ускорители и внешние API без провалов интерактивных задач и каскада ретраев.

Когда несколько команд пользуются одними GPU или одним набором внешних API, проблема редко выглядит как проблема очереди. Сначала она выглядит как жалоба: чат вдруг отвечает по минуте, ночной расчёт не закончен к утру, у поставщика пошли 429, а команда, которая ничего не меняла, спрашивает, почему её сервис стал медленным.
Обычно причина проста. Все задачи стоят в одной очереди, а планировщик считает их одинаковыми. Но запрос на классификацию одного документа, генерация длинного ответа, дообучение и массовый прогон оценок не одинаковы ни по времени, ни по памяти, ни по числу занятых ускорителей. Справедливая очередь GPU начинается не с выбора алгоритма. Она начинается с честного определения того, что именно вы делите.
FIFO делает интерактивный сервис заложником пакетной работы
FIFO обслуживает задания в порядке поступления. Алгоритм прозрачен, лёгок в реализации и вполне пригоден для одного типа короткой работы. В смешанной нагрузке он даёт ровно ту гарантию, которую многие забывают прочитать: кто пришёл раньше, тот получит ресурс раньше, независимо от размера задачи и её владельца.
Представьте одну очередь на восемь слотов GPU. Команда аналитики в 09:00 ставит 400 заданий оценки. Каждое задание генерирует длинный ответ и держит слот несколько минут. В 09:03 продуктовая команда начинает получать обращения пользователей к ассистенту. Их запросы занимают секунды, но они стоят за уже принятыми заданиями оценки. Ускорители могут быть заняты на 70 процентов, а пользовательский путь уже фактически недоступен.
Это не ошибка FIFO. Это ошибка ожиданий от FIFO. Очередь соблюдает порядок поступления, но не различает:
- короткую работу и длинную;
- запрос, который человек ждёт сейчас, и фоновый прогон;
- одну задачу и пачку из тысячи задач;
- работу одной команды и нагрузку всех остальных.
Особенно плохо FIFO ведёт себя при многошаговых заданиях. Если один диспетчер выдал batch задаче четыре слота, она может удерживать их до конца длинного этапа, даже когда в очереди уже накопились десятки коротких интерактивных запросов. Добавить больше GPU иногда помогает, но не меняет порядок обслуживания. При следующем массовом запуске вы получите тот же эффект на более дорогом кластере.
У FIFO всё же есть место. Её можно оставить внутри одного узкого класса, где задания имеют предсказуемую цену, например для коротких операций одного сервиса. Не делайте её глобальной политикой общего пула.
Строгий приоритет спасает срочное, но легко морит голодом остальных
Строгая приоритетная очередь всегда берёт работу из самого высокого непустого класса. Она хорошо защищает путь, который нельзя задерживать: проверку доступности, отмену работы, обработку инцидента, запрос человека в активном диалоге. Она же быстро становится источником скрытой войны за ярлык «срочно».
Если класс P0 непустой большую часть времени, P1 не получает ресурс. Если P1 постоянно занят, batch класс может ждать бесконечно. Это называется starvation, и в реальной эксплуатации он часто выглядит не как бесконечное ожидание, а как задания, которые начинают и отменяют вручную, потому что владельцы перестали доверять очереди.
Популярная рекомендация «дадим каждому сервису приоритет» хуже, чем отсутствие политики. Через несколько месяцев почти каждый сервис получает высокий класс, потому что его владелец может назвать свою задержку важной. Когда все высокоприоритетные, никто не защищён.
Оставьте строгие приоритеты для малого набора случаев, где задержка действительно опаснее справедливости. Для каждого такого класса задайте три ограничения:
- Лимит конкурентности, чтобы срочная работа не съела весь пул.
- Ограничение длины очереди или срока ожидания, чтобы устаревшие задания не копились.
- Правило допуска, которое нельзя обойти сменой поля в запросе.
Приоритет отвечает на вопрос «кого нельзя задерживать». Он не отвечает на вопрос «как честно поделить оставшуюся ёмкость».
Weighted fair queuing делит занятую ёмкость, а не обещает равные задержки
Weighted fair queuing, или WFQ, держит отдельные потоки работы и выдаёт им обслуживание пропорционально весам. Если у команды A вес 4, а у команды B вес 2, при постоянной нагрузке A должна получить примерно вдвое больше обслуживаемой стоимости. Речь именно о стоимости, а не о числе сообщений в очереди.
Документация Kubernetes API Priority and Fairness описывает похожую идею для запросов к API server: разные уровни приоритета получают отдельные пределы конкурентности, а внутри уровня fair queuing не даёт одному потоку вытеснить остальные. Там же отдельно выделена проблема плохо ведущего себя клиента, который заливает сервер запросами. Это полезный пример, потому что очередь строят не ради красивой математики, а ради изоляции соседей по общему ресурсу.
В Linux дисциплина fq тоже разделяет потоки, обслуживает их раундами и использует квант, то есть объём работы, который поток может отдать за один раунд. Больший квант означает, что остальные потоки дольше ждут следующей возможности. Для GPU и API это прямое предупреждение: если выдавать одной очереди слишком большую порцию работы за раз, формально справедливый алгоритм снова даст плохую интерактивную задержку.
WFQ не обещает, что все запросы завершатся за одинаковое время. Длинная задача всё равно длится дольше короткой. Он обещает другое: пока несколько потоков активны, один поток не сможет бесконечно занимать всю доступную долю.
На практике для планировщика вычислений используют приближение WFQ. Для каждого потока хранится виртуальное время завершения следующего задания:
finish = max(flow_finish, virtual_time) + estimated_cost / weight
Диспетчер выбирает задание с наименьшим finish. После выдачи задания он обновляет flow_finish и общее виртуальное время. Вам не нужно симулировать идеальную сеть пакетов. Нужно последовательно использовать одну формулу, хранить состояние атомарно и не позволять одному отправителю создавать новый поток для каждого запроса.
Справедливость ломается, если считать запросы вместо стоимости
Два запроса могут иметь одинаковый HTTP размер и совершенно разную цену. Один просит извлечь пару полей из короткого текста. Второй передаёт длинный контекст, требует подробный ответ, запускает несколько инструментов и потом повторяет вызов при ошибке. Если оба стоят «один запрос», тяжёлая работа получает льготный тариф.
Для GPU удобная единица часто выглядит так:
стоимость = занятые_слоты * измеренные_секунды + штраф_за_память
Для LLM inference предварительная оценка обычно строится до запуска:
estimated_cost = input_tokens + 2 * max_output_tokens
Коэффициент перед выходными токенами зависит от модели и режима. Его нельзя считать физической константой. Возьмите журнал завершённых запросов, сравните прогноз с фактическим временем и регулярно меняйте коэффициенты. Для batch задач, где известны размер набора и параметры генерации, оценка обычно точнее, чем для свободного пользовательского текста.
Не смешивайте три разных ограничения:
- Доля обслуживания определяет, сколько работы получает команда при конкуренции.
- Лимит конкурентности ограничивает, сколько задач команда может держать запущенными одновременно.
- Лимит скорости ограничивает, как быстро команда может создавать новую работу.
Вес WFQ не заменяет лимит конкурентности. Команда с большим весом может честно получать большую долю, но одна её задача всё равно может занять весь GPU, если планировщик допускает такую задачу. Лимит скорости не заменяет вес: команда может медленно отправить тысячу тяжёлых задач и потом часами занимать очередь.
Вместо одного абстрактного «лимита» заведите учёт по ресурсам. Для локальной модели это могут быть секунды GPU, память и число слотов. Для внешней модели это запросы в минуту, токены в минуту, одновременные вызовы и денежный бюджет. Не складывайте эти величины в одну цифру, пока не можете объяснить, что означает каждая часть суммы.
Потоком должна быть команда и класс работы, а не пользователь и не API ключ
Выбор потока определяет, кого планировщик считает соседом. Ошибка здесь обесценивает любой алгоритм.
Если поток равен пользователю, одна команда может получить сотни долей, заведя сотни сервисных аккаунтов. Если поток равен API ключу, тот же эффект возникает после ротации ключей или разделения приложения на микросервисы. Если поток равен только команде, её тяжёлый ночной прогон будет конкурировать с её же интерактивным сервисом, хотя бизнесу это обычно не нужно.
Хорошая стартовая схема:
flow_id = organization_id + workload_class + resource_pool
organization_id задаёт владельца доли. workload_class отделяет интерактивную работу от batch и от служебных операций. resource_pool не даёт задаче, которая ждёт внешний API, занять место в очереди локальных GPU.
Не делайте больше классов, чем можете защищать правилами. Обычно хватает таких:
interactiveдля запросов в активном пользовательском пути;batchдля оценок, индексации, массовой генерации и экспериментов;systemдля отмен, health checks и редких операций поддержки.
Класс должен задавать доверенный сервер, а не клиентский заголовок. Клиент может сообщить намерение, но диспетчер обязан проверить его по маршруту, сервисному аккаунту, типу задачи или отдельному разрешению. Иначе любой batch вызов быстро объявит себя интерактивным.
Короткие запросы защищает оценка размера и маленький квант
WFQ по потокам защищает команды друг от друга, но сам по себе не всегда защищает короткую работу внутри одной команды. Если в interactive попал один запрос с огромным контекстом, а за ним стоят десять коротких, простой порядок внутри потока снова создаст head-of-line blocking.
Самый агрессивный способ решить проблему называется shortest remaining processing time: сначала исполнять работу с наименьшим оставшимся временем. Он хорошо уменьшает среднюю задержку, но в чистом виде может постоянно отодвигать длинные задачи, если поток коротких задач не заканчивается. В продакшене лучше использовать ограниченную версию.
Разделите интерактивный класс на несколько корзин размера, например по прогнозной стоимости. Маленькая задача получает шанс начать быстро, но после ограниченного числа выдач диспетчер обязан взять работу из следующей корзины. Это не идеальная теория, зато политика переживает реальный трафик и её можно объяснить владельцам сервисов.
Есть и более простой вариант: ограничить размер одной порции. Для генерации это означает, что длинный ответ не резервирует всю предполагаемую длину до конца. Диспетчер выдаёт ограниченное число токенов или ограниченное время вычисления, затем решает, продолжать ли работу. Такой подход требует поддержки отмены и возобновления в исполнителе. Если исполнитель не умеет безопасно продолжать задачу, не притворяйтесь, что вы делите её на части. Лучше честно учитывайте полную стоимость и ограничьте число тяжёлых задач.
Нельзя защитить короткие запросы только таймаутом. Таймаут сработает после того, как пользователь уже ждал. Очередь должна решать, что допустить в работу, до запуска.
У GPU и внешнего API должны быть разные бюджеты и разные точки ожидания
Задача, которая вызывает внешнюю модель, не должна занимать локальный слот GPU, пока ждёт сеть или ответ поставщика. Это кажется очевидным, но часто нарушается в воркерах: процесс получил задачу, зарезервировал общий слот, затем пошёл во внешний API и удерживает слот во время ожидания.
Разделите путь на этапы. Локальная подготовка берёт короткий лимит CPU. Вызов внешнего API проходит через очередь конкретного поставщика. Локальная постобработка снова получает свой ресурс. У каждого этапа свой семафор и свой счётчик стоимости.
Для внешнего API полезна комбинация token bucket и очереди справедливости:
provider: external-model-a
limits:
requests_per_minute: 900
tokens_per_minute: 180000
max_in_flight: 24
scheduler:
algorithm: wfq
flow: organization_id + workload_class
weights:
interactive: 6
batch: 1
max_queue_age_seconds:
interactive: 45
batch: 7200
retry:
max_attempts: 3
budget_per_flow_percent: 10
backoff: exponential_with_jitter
Этот фрагмент предотвращает конкретную аварию: batch поток не создаёт столько одновременных вызовов, что интерактивные запросы получают 429 вместе с ним. Он также не даёт ретраям занять больше десятой части бюджета потока. Числа здесь не универсальны. Их нужно выводить из квот поставщика, наблюдаемой задержки и допустимого времени ожидания, а не копировать в прод без измерений.
Документация Google Cloud по квотам отдельно советует назначать разные квоты тяжёлым и лёгким методам, а при 429 использовать повторные попытки с задержкой. Это разумно, но для общего диспетчера одного backoff мало: повтор должен снова пройти через тот же лимит и ту же политику справедливости. Иначе старые задачи обходят очередь при каждой новой попытке.
Отмена и ретраи определяют, будет ли очередь честной под аварийной нагрузкой
Пользователь закрыл страницу, а его генерация продолжает потреблять GPU ещё две минуты. Batch job получила временную ошибку и отправила сотни повторов одновременно. Таймаут клиента закончился, но серверный воркер всё ещё ждёт внешний API. Все три ситуации создают работу, за которую никто больше не готов платить, но очередь продолжает считать её равноправной.
Каждое задание должно иметь deadline, cancel_token и идентификатор идемпотентности. Диспетчер проверяет срок до постановки в очередь, перед выдачей исполнителю и между долгими этапами. Исполнитель проверяет отмену там, где может реально освободить ресурс: перед запуском модели, между частями batch прогона, перед следующим вызовом поставщика, после возврата ответа.
Для повторов задайте отдельные правила:
- Повторяйте только ошибки, для которых операция безопасна или защищена ключом идемпотентности.
- Возвращайте повтор в исходный поток, а не в голову общей очереди.
- Списывайте повтор из бюджета попыток владельца задачи.
- Добавляйте случайную составляющую в задержку, чтобы клиенты не повторили запросы строем.
- Прекращайте попытки, когда
deadlineистёк, даже если лимит попыток ещё не исчерпан.
Немедленный retry после 429 выглядит как реакция на временную проблему. На деле он часто продлевает перегрузку. Если поставщик ограничил поток по токенам, повторить тот же длинный запрос через 100 миллисекунд значит только создать вторую запись о том же нарушении.
Проверяйте политику на искусственной перегрузке, а не по среднему времени ответа
Средняя задержка почти ничего не говорит о справедливости. Она может выглядеть хорошо, пока одна небольшая команда ждёт час, а крупная команда отправляет много коротких задач и тянет среднее вниз.
Собирайте метрики отдельно по владельцу, классу и пулу ресурсов. Минимальный набор выглядит так:
- p50, p95 и p99 времени ожидания до старта;
- фактическая стоимость обслуженной работы по каждому потоку;
- число запущенных задач и длина очереди;
- отмены до старта, отмены во время выполнения и работа после отмены;
- 429, таймауты и доля повторов в общем объёме.
Затем проведите скучный, но показательный тест. Пусть команда A непрерывно отправляет тяжёлый batch. Команда B отправляет короткие интерактивные запросы с постоянной частотой. Команда C иногда даёт всплеск работы. Сначала запустите сценарий на FIFO, потом на строгом приоритете, затем на WFQ с теми же лимитами конкурентности. Сравните не только общее число завершений, но и время старта запросов B, долю ресурса A и то, дождалась ли C хотя бы части своей работы.
Если WFQ дал командам правильные доли, но интерактивные запросы всё ещё ждут неприемлемо долго, проблема обычно не в весах. Проверьте размер кванта, максимальную задачу, число одновременно занятых слотов и порядок внутри потока. Планировщик не может вернуть задержку, которую уже создала задача, занявшая все доступные GPU.
Начинайте с изоляции, которую можно объяснить дежурному инженеру
Не нужно сразу строить идеальный глобальный планировщик. Сначала уберите одну общую очередь для всего. Отделите интерактивную работу от batch, введите лимиты конкурентности на команду, измеряйте фактическую стоимость и запретите повторным попыткам обходить очередь. После этого веса начинают означать что-то полезное.
Когда правила станут стабильны, зафиксируйте их как договор. Укажите, кто владеет каждым потоком, какие классы разрешены, какая доля гарантирована при конкуренции, что происходит при простое соседей и какой срок ожидания считается просроченным. Без этого WFQ остаётся алгоритмом в коде, а не политикой общего ресурса.
Если вызовы идут через AI Router, лимиты на уровне ключа могут отделить потребителей на API-шлюзе, но бизнесовую политику очереди и классификацию работы всё равно стоит держать в приложении или отдельном диспетчере. Иначе ключ ограничит скорость входа, но не решит, чья длинная задача имеет право занять следующий свободный слот.
Справедливость не означает, что каждая команда получает одинаковое. Она означает, что заранее согласованные доли сохраняются именно тогда, когда ресурс стал дефицитным. Это тот момент, ради которого очередь вообще существует.
Часто задаваемые вопросы
Когда FIFO очередь всё ещё подходит для GPU?
FIFO подходит только когда все задания близки по стоимости и один владелец отвечает за весь поток. В общей среде оно превращает длинную пакетную задачу в препятствие для каждого, кто пришёл позже. Если у вас есть интерактивные запросы и ночные прогоны, одной FIFO очереди почти всегда мало.
Можно ли делить GPU справедливо по числу запросов?
Нет, если вы учитываете только число запросов. Один запрос может занять одну секунду GPU, а другой держать несколько ускорителей десятки минут. Считать нужно измеряемую стоимость: секунды GPU, входные и выходные токены, конкурентные слоты или комбинацию этих величин.
Чем опасна очередь со строгими приоритетами?
Приоритет полезен для аварийных и действительно срочных операций, но он не создаёт честного распределения сам по себе. Без лимита доли и отдельного потолка срочный класс способен постоянно вытеснять остальные классы. Оставляйте приоритет только для редких путей, а обычную работу делите весами.
Как выбрать веса команд в WFQ?
Обычно достаточно начать с веса, пропорционального согласованной доле ресурса. Например, команда с весом 4 должна получать примерно вдвое больше обслуживаемой работы, чем команда с весом 2, пока обе очереди заняты. Затем скорректируйте веса по фактической стоимости задач и договорённостям о бюджете.
Что считать потоком в fair queuing для LLM?
Пользователь почти никогда не является правильной единицей справедливости. Один сервисный аккаунт может запускать тысячи задач, а одна команда может использовать десятки аккаунтов. В большинстве компаний рабочая единица выглядит как команда или продукт плюс класс нагрузки, а не отдельный человек.
Как защитить короткие запросы от длинных batch задач?
Сначала отменяйте работу, которую пользователь уже не ждёт, а затем снижайте долю пакетного класса через вес или лимит конкурентности. Не ставьте интерактивную задачу в конец общей FIFO очереди в надежде, что она быстро пройдёт. Для коротких запросов нужен отдельный путь допуска и небольшие кванты обслуживания.
Нужна ли отдельная очередь для внешнего API?
Внешний API ограничен не только пропускной способностью, но и квотами поставщика, лимитами токенов и числом одновременных запросов. Локальная очередь должна выпускать запрос только после проверки собственного бюджета на этого поставщика. Ответ 429 нельзя лечить немедленной повторной отправкой, иначе очередь сама создаст перегрузку.
Почему ретраи могут сломать справедливую очередь?
Потому что ретраи возвращают старые запросы в очередь и занимают место у новой полезной работы. Если все клиенты повторяют запрос одновременно, они создают новую волну нагрузки. Повторяйте только идемпотентные или безопасно восстанавливаемые операции, используйте случайную задержку и ограничивайте общий бюджет повторов.
Какие метрики показывают, что очередь несправедлива?
Смотрите не только на среднее время ожидания. Нужны p95 и p99 ожидания по команде и классу, доля отменённых задач, расход секунд GPU, отказы по лимитам и доля работы, выполненной после отмены клиента. Среднее значение почти всегда скрывает команду, которая ждёт слишком долго.
С чего начать внедрение WFQ в существующей системе?
Сначала разделите интерактивный и пакетный трафик, введите отдельные лимиты конкурентности и начните писать стоимость каждого задания в журнал. После этого можно включить weighted fair queuing для двух или трёх групп и проверить его на искусственном всплеске batch нагрузки. Не начинайте с десятка классов и сложных правил, которые никто не сможет объяснить дежурному инженеру.