Оглавление
- Как выглядит I/O-инцидент на LLM-узле
- Проверяем cgroup v2, device и реальную принадлежность процесса
- Архитектура: sibling slices вместо одного общего лимита
- Снимаем baseline без выдуманных бенчмарков
- Настраиваем лимит через systemd, а не ручной echo
- Два gate: сначала инфраструктура, потом приложение
- Canary rollout и rollback без героизма
- Наблюдаемость: связываем io.pressure с TTFT
- Типовые ошибки и разбор неудачного лимита
- Практический итог: защищаем SLO, а не цифру NVMe
Готовы перейти на современную серверную инфраструктуру?
В King Servers мы предлагаем серверы как на AMD EPYC, так и на Intel Xeon, с гибкими конфигурациями под любые задачи — от виртуализации и веб-хостинга до S3-хранилищ и кластеров хранения данных.
- S3-совместимое хранилище для резервных копий
- Панель управления, API, масштабируемость
- Поддержку 24/7 и помощь в выборе конфигурации
Результат регистрации
...
Создайте аккаунт
Быстрая регистрация для доступа к инфраструктуре
Как выглядит I/O-инцидент на LLM-узле
Утром online inference держит привычный SLO, а во время синхронизации новой модели time to first token внезапно растёт. GPU при этом не перегрет, VRAM свободна, сеть не забита. Знакомо? Часто очередь уже образовалась ниже приложения: большой последовательный read весов и мелкие обращения работающего сервиса делят один NVMe, а Linux честно обслуживает обоих без знания о бизнес-приоритете.
Эта статья решает узкую задачу: отделяет I/O онлайн-сервиса от загрузок моделей, checkpoint и batch-job на одном выделенном сервере с помощью cgroup v2 и systemd slices. Это не продолжение настройки memory pressure. В материале про cgroup v2, PSI и systemd-oomd для LLM граница проходит по RAM и OOM; здесь мы работаем с block I/O, io.max, io.stat и io.pressure.
Цель не в том, чтобы выжать из NVMe абстрактный максимум. Нужен проверяемый контракт: фоновая работа может идти медленнее, зато запросы пользователей не получают хвост latency из-за чужого pull. Мы сначала измерим baseline, потом введём ограничение только для batch-группы, докажем его на infrastructure gate и отдельно проверим приложение. Любое число в примерах — стартовая лабораторная величина, а не готовая рекомендация для вашего диска.

Проверяем cgroup v2, device и реальную принадлежность процесса
Первый риск — ограничить не тот слой. Каталог моделей может лежать на LVM, mdraid, dm-crypt или сетевой файловой системе, а имя /dev/nvme0n1 в инструкции ничего не доказывает. Документация ядра cgroup v2 задаёт io.max по паре major:minor block device; значит, этот идентификатор нужно получить на конкретном узле.
Сначала подтвердите unified hierarchy и наличие контроллера io. Затем найдите источник mount point модели, разверните device-mapper до физических устройств и проверьте ControlGroup каждой службы. Если downloader запущен из интерактивной shell, Kubernetes init container или cron вне ожидаемой slice, красивый unit-файл ничего не изменит.
stat -fc %T /sys/fs/cgroup
cat /sys/fs/cgroup/cgroup.controllers
findmnt -no SOURCE,TARGET,FSTYPE /var/lib/models
lsblk -o NAME,MAJ:MIN,TYPE,FSTYPE,MOUNTPOINTS
systemctl show vllm.service model-sync.service -p Slice -p ControlGroup
Infrastructure gate здесь простой: во время пробной загрузки должны расти rbytes или rios именно в cgroup фоновой службы. Отрицательная проверка не менее важна: счётчики inference-группы не должны внезапно отражать весь bulk read. Если отражают, остановитесь и исправьте placement процессов до настройки лимитов.
Архитектура: sibling slices вместо одного общего лимита
Удобная схема состоит из двух соседних ветвей: llm-inference.slice и llm-batch.slice. В первой живёт vLLM или другой runtime, во второй — model sync, конвертация, checkpoint upload и вспомогательные job. Ограничение ставится на batch, а inference остаётся без искусственного потолка. Такой дизайн локализует изменение и делает rollback одной операцией.
Почему не поставить высокий IOWeight inference и забыть? Вес — относительный механизм: он проявляется при конкуренции sibling-групп и зависит от планировщика и модели устройства. io.max задаёт понятный верхний предел BPS/IOPS для шумного соседа. В проде часто начинают с жёсткого ceiling для bulk workload, а веса тестируют позже. Ядро допускает временные bursts, поэтому короткий пик всё равно возможен.
Не смешивайте эту задачу с доставкой модели. OCI-артефакты и node-local cache сокращают объём повторных скачиваний; отдельная статья про кэш весов LLM на NVMe разбирает именно этот слой. I/O isolation отвечает на другой вопрос: что произойдёт, когда загрузка всё-таки началась рядом с работающим inference.

Снимаем baseline без выдуманных бенчмарков
Не начинайте с числа вроде «500 MB/s достаточно всем». Один NVMe может устойчиво держать последовательное чтение и резко проседать на смешанной очереди; другой упирается в thermal throttling, RAID или шифрование. Baseline должен объединять три слоя: характеристики block I/O, pressure внутри cgroup и пользовательскую latency приложения.
Для лабораторного профиля подходит fio, но только на отдельном test file и с согласованным размером. Актуальная документация fio 3.42 позволяет фиксировать rate, IOPS и latency percentiles. Не запускайте destructive job на device с рабочими весами. Сначала воспроизведите профиль downloader: крупный sequential read, затем добавьте небольшой random-read поток, похожий на обращения runtime к файлам модели.
[global]
directory=/var/lib/models/io-probe
size=8G
direct=1
ioengine=io_uring
runtime=120
time_based=1
group_reporting=1
percentile_list=95:99:99.9
[bulk-model-read]
rw=read
bs=1M
iodepth=16
[inference-side-read]
rw=randread
bs=128k
iodepth=4
rate_iops=200
Значения в job-файле — форма эксперимента, а не performance claim. Замените size, queue depth и rate профилем своего приложения. Параллельно соберите TTFT и queue time на фиксированном наборе запросов. В текущей документации vLLM per-request metrics помечены как доступная функция, но включение может давать заметный CPU overhead при высокой конкуренции, поэтому его тоже нужно проверить на своей версии и нагрузке.

Настраиваем лимит через systemd, а не ручной echo
Ручная запись в /sys/fs/cgroup/.../io.max годится для диагностики, но не для устойчивой конфигурации. systemd создаёт иерархию, переносит процессы и восстанавливает свойства после рестарта. Сначала закрепите version lock: зафиксируйте kernel, systemd, filesystem, I/O scheduler и unit revision в change ticket. При обновлении любого из этих слоёв gates повторяются.
Ниже пример drop-in для фоновой службы. Пределы 600M read и 150M write намеренно показаны как лабораторная отправная точка. На вашем сервере они должны следовать из baseline и оставлять запас под online workload.
[Service]
Slice=llm-batch.slice
IOAccounting=yes
IOReadBandwidthMax=/dev/nvme0n1 600M
IOWriteBandwidthMax=/dev/nvme0n1 150M
После systemctl daemon-reload и рестарта службы проверьте эффективные значения через systemctl show и соответствующий io.max. Не переносите в batch.slice сам inference, journald или kubelet: тогда потолок начнёт ограничивать критическую инфраструктуру. Для контейнеров граница должна проходить на уровне unit/scope, который действительно владеет workload.
Для buffered writes есть существенное ограничение. Ядро атрибутирует writeback корректно только на поддерживаемых файловых системах, среди которых ext4, btrfs, f2fs и XFS; на неподдерживаемых writeback может уйти в root cgroup. Ещё хуже общий inode, который одновременно dirty несколько групп: ownership может переключаться, и учёт становится неточным. Поэтому checkpoint каждого job лучше писать в отдельный файл и каталог.

Два gate: сначала инфраструктура, потом приложение
Infrastructure gate доказывает механику. Запустите контролируемый bulk read в llm-batch.slice, убедитесь, что его средняя скорость приблизилась к ceiling, а счётчики io.stat растут на нужном major:minor. Затем выполните отрицательный тест: процесс вне batch.slice не должен получить тот же потолок. Это защищает от самой неприятной ошибки — случайно ограничить весь сервер и принять деградацию за «работающий QoS».
Application gate отвечает на пользовательский вопрос. Под тем же фоновым потоком отправьте заранее сохранённый набор inference-запросов. Сравните TTFT, queue time, error rate, tokens per second и readiness с baseline. Никаких универсальных процентов: gate проходит только по вашему SLO и заранее установленному error budget. Отдельно проверьте холодный старт новой model revision, иначе слишком жёсткий rbps превратит rollout в бесконечный NotReady.
В платформе Kubernetes тест не заменяет проверку scheduling. Статья про KServe, GPU Operator, Kueue и autoscaling рассматривает cluster-level orchestration; здесь unit-level gate остаётся обязательным на каждом GPU-узле, где online и background I/O делят накопитель.
Canary rollout и rollback без героизма
Начинайте с одного узла, одной model revision и одного типа фоновой операции. Зафиксируйте окно наблюдения, затем включите предел на canary. Если downloader стал медленнее, а application gate проходит, расширьте rollout на небольшую долю узлов. Если TTFT ухудшился, не «дотягивайте» эксперимент до конца: снимите свойство, перезапустите только фоновую службу и верните workload к baseline.
Rollback должен существовать до canary. Для transient-настройки это сброс property к infinity; для drop-in — удаление конкретного override, daemon-reload и restart batch unit. Не перезапускайте vLLM без необходимости: изменение принадлежит фоновой ветви. После отката снова снимите io.stat, PSI и application metrics, чтобы доказать возврат, а не просто отсутствие алерта.
Есть и организационная ловушка. Команда моделей может ускорить sync увеличением параллелизма, не меняя формальный BPS. Тогда IOPS и queue depth меняются, а прежний ceiling перестаёт защищать latency. Поэтому rollout contract включает владельца unit, допустимые downloader flags и повторный baseline при смене формата весов, filesystem или storage topology.

Наблюдаемость: связываем io.pressure с TTFT
io.pressure показывает долю времени, когда задачи stalled из-за I/O. В PSI строка some означает, что остановилась хотя бы часть задач, full — что одновременно простаивают все non-idle tasks группы. Метрики доступны на уровне системы и каждой cgroup; именно per-cgroup сигнал помогает не обвинять NVMe во всех проблемах узла.
Собирайте три линии на одной временной шкале: delta rbytes/wbytes/rios/wios из batch.slice, some/full из её io.pressure и application latency. Kernel PSI docs также описывают poll-trigger: userspace может получить событие, когда накопленный stall превысил порог в окне. Порог выводят из нормального профиля, а не копируют из статьи.
CG=/sys/fs/cgroup/llm-batch.slice
cat "$CG/io.stat"
cat "$CG/io.pressure"
systemctl show model-sync.service -p ControlGroup -p IOReadBandwidthMax -p IOWriteBandwidthMax
curl -fsS http://127.0.0.1:8000/metrics | grep -E 'time_to_first_token|request_queue_time'
Корреляция не равна причине. Если TTFT вырос, а batch rbytes не изменился, ищите CPU throttling, memory reclaim, network storage или GPU fault. Хороший dashboard не скрывает отрицательные данные: нулевая активность batch при деградации — сильный аргумент снять лишний I/O-лимит и исследовать другой слой.

Типовые ошибки и разбор неудачного лимита
Лимит не действует. Чаще всего указан partition вместо фактического device-mapper target, процесс живёт не в той cgroup, I/O идёт через другой mount или buffered writeback не атрибутируется filesystem. Сравните major:minor в io.stat с lsblk и findmnt; только потом меняйте rate.
Сервис загружается слишком долго. Ceiling защитил online traffic, но нарушил startup deadline. Решение — не отключать readiness. Ослабьте предел для controlled rollout, прогрейте модель заранее или разделите sync и load на разные этапы. Readiness должна оставаться failure-visible, иначе запросы попадут в runtime, который ещё читает веса.
TTFT всё равно растёт. Возможно, причина выше block layer: page cache reclaim, CPU contention, NUMA placement, remote filesystem или saturation PCIe. Проведите отрицательный контроль: полностью остановите batch unit, оставив тот же набор inference-запросов. Если TTFT не вернулся к baseline, I/O-лимит не лечит текущий инцидент. Снимите его и переходите к CPU, memory, network и GPU telemetry. Такая проверка полезнее последовательного ужесточения rbps, потому что отделяет совпадение по времени от причинной связи.
Узкое место переместилось. После ограничения чтения downloader может начать дольше удерживать CPU, network socket или temporary space. Смотрите на весь путь: registry или NFS → page cache → block device → runtime → GPU. Если очередь переехала в сеть, NVMe PSI снизится, но rollout не станет безопаснее. Зафиксируйте duration синхронизации, свободное место, retry rate и readiness нового экземпляра. При превышении maintenance window возвращайте исходную конфигурацию, а не маскируйте проблему увеличением startup deadline.
Вес ухудшает ситуацию. IOWeight не является гарантией bandwidth и может требовать io.cost/controller support. Начните с проверяемого io.max, а эксперимент с weight проводите отдельно. Не включайте io.latency вслепую: kernel docs прямо советуют подобрать target по устройству и иерархии sibling-групп.

Практический итог: защищаем SLO, а не цифру NVMe
I/O-изоляция полезна, когда один GPU-узел одновременно обслуживает запросы и выполняет тяжёлую доставку моделей, checkpoint или batch read. Рабочая схема проста по форме, но требовательна к доказательствам: отдельные sibling slices, ceiling только на фоновой ветви, корректный major:minor, per-cgroup учёт и два независимых gate.
Не начинайте с «правильных» мегабайт в секунду. Снимите baseline на своём kernel, systemd, filesystem и NVMe; зафиксируйте application SLO; затем проведите canary с готовым rollback. Если фон замедлился, а TTFT и readiness удержались, лимит выполняет работу. Если нет — откатите изменение и исследуйте другой слой, не расширяя привилегии и ограничения наугад.
Сохраните результаты canary рядом с unit revision: backing device, лимиты, профиль фоновой операции, PSI и application metrics. Этот короткий operational record пригодится при следующей смене модели или диска. Без него команда неизбежно начнёт спорить с памятью прошлого инцидента, а не с измерениями.
Следующий практический шаг — выбрать один GPU-сервер, воспроизвести model-sync в контролируемом окне и пройти checklist из статьи. Для нового контура лучше сразу разнести model cache и online runtime по понятным units: так каждое изменение остаётся локальным, наблюдаемым и обратимым.