# Дефект: запуск без флагов падал при памяти уровня 4090 — починка найдена и замерена Дата: 2026-07-26. Автор: Opus 5. Состояние: **починка проверена, к внедрению**. Квитанции: `flagless_ada_equiv.json` (отказ), `fix_full.json` (починка) на арендованной RTX 5090. --- ## ПОЧИНКА: две правки вместе, проверено штатным путём ``` raw_1010k не влезает (23.03 ГиБ доступно) -> выбран long_262k long_262k просит долю 0.95, свободно 23.50 из 31.84 -> прижато до 0.723 PASS, окно 262 144, чанк 4096, кодек turboquant_3bit_nc, модель ответила ``` | | значение | |---|---| | свободно на отборе | **25 229 197 312** — *меньше*, чем 25 757 220 864 у 4090 всего | | выбранное окно | 262 144 | | доля памяти | 0.723 (прижата с 0.95) | | загрузка | 73.7 с | Правки: 1. `long_393k.requires_free_bytes` → **25 571 033 088** (замеренный порог: работает отсюда, не работает при 25 371 803 648). Теперь отбор отвергает его на 24 ГБ. 2. `long_262k.gpu_memory_utilization` → **0.95**. Без этого поля `clamp_utilization` возвращает `None` и прижимать нечего. Ни одной переменной окружения профиля не задавалось — проверялся именно тот путь, которым пойдёт пользователь. Конфиг после прогона возвращён и возврат проверен. **Одной правки недостаточно, обе нужны вместе.** Отдельная проверка «дописать только долю» провалилась, и провалилась дважды по разным причинам: первый раз скрипт был помечен «с явной долей», но долю нигде не задавал; второй раз доля была задана, но профиль форсировался через `ZENIT_SERVING_PROFILE`, а при явно затребованном профиле прижатие не срабатывает. ## Требование `long_262k` тоже занижено — замерено Оно объявляет `requires_free_bytes = 22 378 037 226` (20.84 ГиБ). Лестница: | свободно на отборе | итог | |---:|---| | 20 470 759 424 (19.06 ГиБ) | отказ — ожидаемо, меньше самих весов с ареной (19.79) | | 21 471 100 928 (20.00 ГиБ) | отказ | | **22 471 442 432 (20.93 ГиБ)** | **отказ — а это уже БОЛЬШЕ объявленного требования** | Третья строка доказывает занижение: памяти больше, чем профиль просит, а он не поднимается. Значит после починки `long_393k` дефект просто переехал бы на карту поменьше — там отбор дошёл бы до `long_262k`, и тот бы упал. Верхние ступени идут. Прогноз, записанный до чисел: если запас сверх весов и арены такой же, как замерен у `long_393k` (нужно от 2.42 ГиБ), порог лежит около 23.85 млрд байт. Это заодно разрешит вопрос, который я оставил открытым выше: постоянен ли запас. Прежний вывод «не постоянен» опирался на **оценку** свободной памяти безголовой 5090, которую я никогда не мерил. Третья замеренная точка решит по-настоящему. --- --- ## Что случилось Балласт в отдельном процессе занял память на 5090 так, чтобы свободными осталось 25 229 197 312 байт — на полгигабайта **меньше**, чем 25 757 220 864 байта, что есть у 4090 всего. Затем `LLM(model=...)` без единого дополнительного аргумента. Блок профилей отработал правильно, и это надо сказать прямо: ``` raw_1010k не влезает (23.03 ГиБ доступно) -> выбран long_393k, окно 393 216 long_393k просит долю 0.95, свободно 23.50 из 31.84 -> прижато до 0.723 профиль подал: kv_cache_memory_bytes=5200000000, skip_mm_profiling=True, ... ``` Дальше движок: «Initial free memory 23.0 GiB, reserved 4.84 GiB for KV Cache as specified by kv_cache_memory_bytes and **skipped memory profiling. This does not respect the gpu_memory_utilization config.**» И падение на автотюнере FP4: `OOM detected, falling back to default tactic`, затем `torch.OutOfMemoryError: Tried to allocate 272.00 MiB. GPU 0 has ... 149.06 MiB free`, и `Engine core initialization failed`. ## Почему это важно, а не просто «мало памяти» **Прошлый успешный прогон на живой 4090 шёл мимо блока профилей.** `ADA_SM89_RECEIPT` передавал `--max-model-len` и долю 0.92 явно, получил арену на 365 524 токена и оставил 1.09 ГиБ свободными. То есть путь с флагами работает, а путь без флагов — тот самый, который карточка объявляет главным достоинством, — на той же памяти падает. Механизм понятен из лога. Закреплённая арена `kv_cache_memory_bytes` **отменяет** профилирование памяти, поэтому прижатая доля 0.723 ни на что не влияет: вместо неё берётся жёсткие 4.84 ГиБ под кэш. Веса 16.2 ГиБ плюс арена 4.84 ГиБ — это 21.04 ГиБ, и на активации с рабочей памятью автотюнера остаётся около двух, чего не хватило. Ирония в том, что закреплённая арена появилась ради надёжности: без неё vLLM гонял свой профилирующий проход, а он падал в `humming_gemm` с `CUDA_ERROR_INVALID_VALUE`. Лечение одного отказа поставило другой. ## Чего этот замер НЕ доказывает Что падает настоящая 4090. Мой движок видел 23.0 ГиБ свободных, а безголовая 4090 даёт около **23.9**: два лишних контекста CUDA (мой родительский процесс 498 МиБ и процесс балласта) делают пробу жёстче настоящей карты почти на гигабайт. Откуда 23.9. Из `ADA_SM89_RECEIPT`: при доле 0.92 движок взял арену на 365 524 токена, то есть 4.35 ГиБ при 12 785 Б/токен, плюс веса 16.2 ГиБ, и оставил `free_bytes_after_load` = 1 169 948 672, то есть 1.09 ГиБ. Сумма 21.64 ГиБ занятого и 1.09 свободного при полных 23.99 ГиБ карты даёт изначально свободные почти 23.9. Первая оценка в этом файле («23.5–23.7») была занижена, я её исправил. Разница существенна: гигабайт — это ровно тот масштаб, на котором отказ переворачивается, и потому ступени лестницы 26.5 и 27.25 ГБ (движку достанется примерно 23.7 и 24.4 ГиБ) обхватывают настоящую 4090 с обеих сторон. Но и обратного он не доказывает. **Запас здесь меньше гигабайта**, и до сегодня никто его не мерил, потому что все пробы передавали параметры явно и шли мимо профилей — та же слепая зона, которая в прошлый раз спрятала четыре ошибки. ## Лестница: порог найден с точностью до 199 МБ Все числа — свободная память **на отборе**, то есть то, что видит `torch.cuda.mem_get_info()` до запуска движка. Именно с этой величиной сравнивается `requires_free_bytes`. | свободно на отборе | итог | окно | доля | |---:|---|---:|---:| | 25 229 197 312 (23.50 ГиБ) | **FAIL** | — | — | | 25 371 803 648 (23.63 ГиБ) | **FAIL** | — | — | | **25 571 033 088 (23.81 ГиБ)** | **PASS** | 393 216 | — | | 25 971 589 120 (24.19 ГиБ) | PASS | 393 216 | 0.744 | | 26 720 272 384 (24.88 ГиБ) | PASS | 393 216 | 0.766 | | 27 471 052 800 (25.58 ГиБ) | PASS | 393 216 | 0.787 | **Порог: между 25 371 803 648 и 25 571 033 088.** Профиль объявляет `requires_free_bytes = 24 053 735 802` — то есть занижает примерно на **1.4 ГБ**. ### Что это значит для настоящей 4090 У неё всего 25 757 220 864 байта. Из них уходит драйверный резерв и контекст CUDA процесса, где работает отбор, — в сумме порядка 0.5–0.7 ГБ. Значит на отборе остаётся примерно **25.0–25.2 млрд**, а порог — **25.5 млрд**. **Не хватает нескольких сотен мегабайт.** Оговорка честная: настоящей 4090 у меня нет, и величина драйверного резерва на ней не замерена, а замерена только на 5090. Но даже самая благоприятная оценка кладёт 4090 вплотную к порогу, где её сдвинет любая мелочь — графическая сессия, другой драйвер, вторая программа на карте. **Установлено твёрдо, независимо от точного порога:** профиль объявляет `requires_free_bytes = 24 053 735 802` (22.40 ГиБ), а при 25 229 197 312 байтах свободных (23.50 ГиБ) не работает. Объявленное требование занижено **минимум на 1.09 ГиБ**. Отбор из-за этого пропускает профиль на картах, где он не поднимется. Заметить стоит и то, что даже в проходящих случаях доля прижимается до 0.744 и 0.766: объявленные профилем 0.95 недостижимы ни в одном сценарии с 24-гигабайтной картой. ## Это одна заниженная константа, а не одно плохое число Числа взяты из самого движка, а не из моих оценок: ``` gpu_model_runner: Model loading took 16.44 GiB memory gpu_worker: Initial free memory 23.0 GiB, reserved 4.84 GiB for KV Cache -> ОТКАЗ gpu_worker: Initial free memory 23.7 GiB, reserved 4.84 GiB for KV Cache -> РАБОТАЕТ ``` Веса 16.44 плюс арена 4.84 — это **21.28 ГиБ**. Значит сверх них нужно **больше 1.72 и достаточно 2.42 ГиБ** на активации, рабочую память ядер и автотюнер FP4. Сколько закладывают профили (при тех же 16.44 ГиБ весов): | профиль | объявлено | веса + арена | заложено сверх | замер говорит | |---|---:|---:|---:|---| | `long_262k` | 20.84 ГиБ | 19.79 | **1.05** | мало | | `long_393k` | 22.40 ГиБ | 21.28 | **1.12** | мало, замерено | | `raw_1010k` | 29.97 ГиБ | 28.69 | **1.28** | мало | ### Константа всё-таки есть — замерено, и это отменяет мою же ретракцию ниже Порог `long_262k` замерен лестницей: отказ при 23 461 298 176, работает от 23 970 906 112. Теперь есть **две** настоящие точки, и запас считается без всяких оценок: | профиль | веса + арена | запас при отказе | запас при работе | |---|---:|---:|---:| | `long_262k` | 21 252 315 587 | 2 208 982 589 | **2 718 590 525** | | `long_393k` | 22 852 315 587 | 2 519 488 061 | **2 718 717 501** | **Совпадение до 127 килобайт, то есть до 0.005%.** Запас постоянен и равен примерно 2.72 ГБ независимо от профиля. Раздел ниже, где я это опроверг, **неверен**. Он опирался на оценку свободной памяти безголовой 5090, которую я никогда не мерил, — и я сам отметил тогда, что это оценка, но вывод всё равно сделал. Замер восстановил гипотезу, которую я поспешил снять. Текст оставляю как есть, чтобы ход рассуждения был виден. ### Тонкость единиц: отбор сравнивает не с сырой свободной памятью `memory_budget` заранее скидывает свободную память на `_UTILIZATION_SAFETY = 0.98`, и скидка **задумана**: в комментарии к коду записан замер, где прогон видел 29.06 ГиБ при отборе, а к моменту собственной проверки vLLM оставалось 28.45 — графическая сессия разрастается, пока движок грузится. Значит `requires_free_bytes` сравнивается с `free × 0.98`, а мои пороги замерены в **сырой** свободной памяти. Одно на другое не делится молча: | профиль | требование | нужно сырых | замерено, что работает от | отбор строже на | |---|---:|---:|---:|---:| | `long_262k` | 23 972 315 586 | 24 461 546 516 | 23 970 906 112 | 490 МБ (2.0%) | | `long_393k` | 25 572 315 586 | 26 094 199 578 | 25 571 033 088 | 523 МБ (2.0%) | **Оставляю как есть, и это осознанное решение, а не недосмотр.** Замер снимался с неподвижным балластом, а на живой машине память подтекает между отбором и запуском — ровно на те 0.5 ГБ, что даёт скидка. Профиль предлагается только когда сверх замеренного минимума есть подушка на этот дрейф. Цена: на картах со свободной памятью между 25.57 и 26.09 млрд байт выберется 262 144 вместо 393 216, хотя 393 216 там поднялся бы. Полоса узкая, а несимметричность очевидна: предложить профиль, который падает, много хуже, чем предложить окно поуже. ### Что из этого ставить в конфиг | профиль | ставить | объявлено | занижено на | |---|---:|---:|---:| | `long_262k` | 23 972 315 586 | 22 378 037 226 | 1.59 ГБ | | `long_393k` | 25 572 315 586 | 24 053 735 802 | 1.52 ГБ | | `raw_1010k` | **не трогать** | 32 176 640 076 | — | ### `raw_1010k`: замерен настолько, насколько инструмент вообще способен Сперва я записал, что проверить его нечем — «карты больше 32 ГиБ нет». Это была ошибка рассуждения: порог ищется и снизу, занижением балласта. Лестницу я прогнал, и вот что она дала. Свободно на карте без балласта — **33 136 836 608**. Профиль форсировался (`requires_free_bytes=1`), иначе лестница мерила бы не его. | свободно | итог | |---:|---| | 32 470 663 168 | отказ | | 32 072 204 288 | отказ | | 31 671 648 256 | отказ | **Но эти отказы ничего не говорят о рабочем конфиге.** Отбор сравнивает с `free × 0.98`, поэтому `raw_1010k` выбирается только при сырых `free ≥ 32 833 306 200`. Все три ступени ниже этого — при настоящем конфиге профиль там просто не был бы предложен, и отказ вызван моим принудительным обнулением требования. Непроверенной остаётся полоса от 32 833 306 200 до максимума карты 33 136 836 608, шириной **304 МБ**. И она недостижима: балласт держит собственный контекст CUDA, который больше этой полосы. **Инструмент не может измерить этот профиль, потому что съедает ровно тот запас, который проверяет.** Что отсюда следует. Объявленное требование `32 176 640 076` **не показано неверным**, а единственный участок, где оно могло бы быть неверным, этим инструментом не достаётся. Профиль остаётся нетронутым — и теперь не «на всякий случай», а потому что замер уперся в свой предел, и предел назван. Косвенное подтверждение, что константа всё-таки держится и здесь: по ней требуется 33 522 315 586. Обычный `vllm serve` на безголовой карте не создаёт ни процесса балласта, ни родительской пробы, поэтому видит примерно на 0.5 ГБ больше моего максимума — то есть около 33.6 млрд, и профиль впритык помещается. Ровно это и записано в карточке словами «графическая сессия держит около гигабайта, и тогда миллион не влезает». --- ### Поправка к заголовку этого раздела: константы не получилось (СНЯТО, см. выше) Сначала я написал, что это одна заниженная константа на все три профиля, и хотел пересчитать требования вычитанием. **Данные этого не поддерживают, и я снимаю формулировку.** Считая по тем же логам: у арендованной 5090 всего 31.36 ГиБ (движок сам сообщает это в тексте OOM). Безголовый запуск на ней поднял `raw_1010k`, у которого веса с ареной дают 28.69 ГиБ, — то есть он **сработал** с запасом около 1.7 ГиБ. А `long_393k` с запасом 1.72 ГиБ **упал**. Один профиль падает там, где другой работает при том же запасе. Значит запас зависит от профиля — вероятно, от размера и характера выделения арены, — и выводить его вычитанием из одного замера нельзя. Что из этого следует практически: **чинить можно только то, что замерено.** Порог `long_393k` замерен и его требование надо ставить по нему. Пороги `long_262k` и `raw_1010k` не замерены, и подставлять им выведенное число — это ровно та ошибка, которая уже привела к нынешнему дефекту: требование, полученное оценкой вместо замера. Их надо промерить такой же лестницей. Попутно: карточка говорит «веса занимают 16.2 ГиБ». Движок сообщает **16.44**, на диске файлы весят 15.68. Верно движковое, и это надо поправить. ## Третий дефект, отдельный: `long_262k` не задаёт долю памяти Гипотезу «отбор должен был выбрать `long_262k`» я проверил принудительно, и она не спасает: этот профиль на той же памяти падает тоже, но **по другой причине**. ``` ValueError: Free memory on device cuda:0 (23.0/31.36 GiB) on startup is less than desired GPU memory utilization (0.92, 28.85 GiB). ``` Доля 0.92 — это умолчание vLLM. В конфиге у `long_262k` поля `gpu_memory_utilization` **нет вообще** (у `long_393k` и `raw_1010k` стоит 0.95). А `clamp_utilization` начинается со строки `wanted = body.get(...)` и при `None` сразу возвращает `None`, то есть **прижимать нечего и прижатия не происходит**. Профиль, которому нужно 19.79 ГиБ, падает на карте с 23.0 свободными — не от нехватки памяти, а потому что некому было опустить умолчание. Складывая всё вместе: на 24-гигабайтной карте **без флагов не работает ни один профиль**. `long_393k` выбирается и падает с OOM на автотюнере; `long_262k`, если бы отбор до него дошёл, упал бы с ValueError ещё раньше. Карточка при этом объявляет запуск без флагов главным удобством и обещает 4090 окно 393 216. Три причины, три разные починки: 1. `long_262k` обязан нести явную `gpu_memory_utilization`, иначе прижатие для него мертво. Проверяется прогоном, он поставлен в очередь. 2. `requires_free_bytes` надо ставить по замеренному порогу, а не по оценке. 3. Прижатие доли бессмысленно, пока арена закреплена: `kv_cache_memory_bytes` отменяет профилирование памяти целиком. Либо арена ужимается вместе с долей, либо профиль должен признаваться неподходящим и уступать место следующему. ## Чего пока не знаю Вилка порога — 742 МБ, от 25 229 197 312 до 25 971 589 120. Свободное на отборе у безголовой 4090 (всего 25 757 220 864) попадает **внутрь этой вилки**, поэтому вопрос «работает ли настоящая 4090 без флагов» пока открыт. Идёт тонкая лестница на 25.9 / 26.1 / 26.3 ГБ, которая сузит вилку примерно до 200 МБ и вопрос закроет. Параллельно проверяется гипотеза, что на такой памяти отбор обязан был выбрать `long_262k`: у него веса плюс арена дают 19.37 ГиБ против 21.04 у `long_393k`, то есть на активации остаётся 3.63 ГиБ вместо 1.96. ## Что править, когда порог будет известен 1. Если порог выше памяти безголовой 4090 — `long_393k` **непригоден на 24 ГБ**, и нужен профиль поуже (окно 262 144 при меньшей арене), а строка «RTX 4090 → 393 216» в карточке неверна и должна стать либо меньшим окном, либо честным «требует явных флагов». 2. Независимо от порога: `requires_free_bytes` профиля посчитан по прогону, где арена задавалась иначе, и его надо пересчитать из замеренного порога, а не из оценки накладных расходов. 3. Рассмотреть уменьшение `kv_cache_memory_bytes` у `long_393k`: 5.2 ГБ дают 406 726 токенов ёмкости при окне 393 216, то есть запас 3.4%. Он не бесплатен, и на 24 ГБ его, похоже, не на что тратить. ## Урок, снова тот же Слепая зона в замерах — там, где путь пользователя отличается от пути пробы. Каждая проба передавала параметры явно, поэтому блок профилей ни разу не исполнялся под замером. Первый же запуск по-пользовательски нашёл четыре ошибки в прошлый раз и пятую сейчас. --- ## Четвёртый дефект, найденный последним: бюджет насыщается и перестаёт различать Обнаружен, когда `raw_1010k` был **выбран** при 33 568 063 488 свободных байт и упал, а при 33 167 507 456 — правильно уступил `long_393k`. То есть отбор отвергал профиль там, где памяти меньше, и предлагал там, где её больше, но всё равно недостаточно. Причина в `memory_budget`: ```python usable_free = float(free_bytes) * _UTILIZATION_SAFETY # 0.98 return int(min(float(total_bytes) * float(utilization), usable_free)) ``` Доля профиля `raw_1010k` была **0.95**, то есть **меньше защитного множителя 0.98**. Значит при большой свободной памяти связывает первое слагаемое, бюджет упирается в потолок `total × 0.95` и **перестаёт зависеть от свободной памяти вообще**: | свободно | бюджет | чем связано | |---:|---:|---| | 33 568 063 488 (падает) | 32 481 371 750 | `total × 0.95` | | 34 188 820 480 (работает) | 32 481 371 750 | `total × 0.95` | Точка отказа и точка работы дают **одно и то же число**. Никакое `requires_free_bytes` их не различит: сравнение идёт с величиной, которая насытилась. ### Починка, и она в конфиге, а не в коде Сперва я записал это как «дефект логики, править не буду в третьем часу». Это было неверно: достаточно поднять долю профиля **выше** защитного множителя, и связывать начнёт `free × 0.98`, а бюджет снова станет следить за свободной памятью. Поставлено: `gpu_memory_utilization` **0.99**, `requires_free_bytes` **33 200 000 000**. Тогда бюджет при отказе 32 896 702 218 — отвергается, при работе 33 505 044 070 — принимается. В сам vLLM уйдёт 0.980: прижатие считает посильную величину отдельно и опустит 0.99 до неё. ### Урок Защитный множитель 0.98 и доля профиля 0.95 выглядели независимыми числами, а на деле их **порядок** решает, работает ли отбор. Пока доля ниже множителя, проверка «влезет ли» слепа ко всему, что выше потолка. Это не опечатка и не просчёт в арифметике — это связь между двумя константами, которую никто не записал. --- ## Открепление арены: проверено и отвергнуто Гипотеза была хорошая. Движок сам пишет в лог, что закреплённая `kv_cache_memory_bytes` **отменяет профилирование памяти** и «does not respect the gpu_memory_utilization config». Значит и отбор, и сам vLLM слепы к одной величине — тем 570 МБ, что появляются после старта процесса движка. Открепить арену — и зрение вернулось бы хотя бы движку. Причина, по которой её закрепили, к тому же отпала: без закрепления профилирующий проход падал в `humming_gemm`, но `skip_mm_profiling` появился позже и снимает это. Проверено — падение не вернулось. **На краю открепление дало заметно лучший отказ:** ``` Available KV cache memory: 11.76 GiB ValueError: To serve at least one request with the model's max seq len (1010001), 12.02 GiB KV cache is needed, which is larger than the available (11.76 GiB) ``` Обе цифры, причина, никакой трассировки — вместо `torch.OutOfMemoryError: Tried to allocate 272.00 MiB` посреди инициализации. **Но на полной карте оно ломает всё:** ``` Available KV cache memory: 11.85 GiB -> окну нужно 12.02 -> отказ ``` Собственная оценка vLLM слишком осторожна: она даёт 11.85 ГиБ там, где закреплённая арена берёт 12.15. Без закрепления миллион не поднимается **нигде**, включая пустую карту. **Вывод: закрепление остаётся.** Это не рудимент, а именно то, что делает окно миллиона возможным — оно перекрывает заниженную оценку движка. Цена известна и записана: на краю отказ выглядит как OOM, а не как объяснение. Показательная деталь напоследок: профилировщик vLLM даёт 11.85 ГиБ на полной карте и 11.76 на краю — **90 МБ разницы**, ровно столько же, сколько видит отбор. Оба слепы к одному и тому же. Это независимо подтверждает, что край предельный по существу, а не создан пробой.