← На главную

Хаб курсов / LLM Inference Systems / глава 0

Модуль 00: Нужен ли тебе свой движок инференса и ландшафт путей

Три пути к своему LLM-движку: MLX/Apple Silicon, CUDA/PyTorch, CPU/GGUF. Арифметика памяти, Qwen3 как подопытная, ландшафт сервинга vLLM, SGLang, llama.cpp.

LLM Inference Systems. Модуль 00: Ландшафт инференса LLM

Модель обучают один раз и за 10–100 млн USD, а инференс случается на каждый запрос, и за него платят. Из этого простого факта растёт целая дисциплина: обучение ограничено FLOPs и терпением исследователя, инференс ограничен памятью, задержкой и ценой за миллион токенов. Инженер, который умеет считать байты / пропускная способность быстрее, чем открывать конфиг модели, экономит работодателю порядки в счёте за GPU.

Три пути инференса: веса модели расходятся в MLX, CUDA и CPU-GGUF стеки

В этом модуле мы не пишем движок — мы строим бюджет. Считаем руками (и проверяем кодом), сколько занимают веса Qwen3-0.6B и Qwen3-8B, сколько занимает KV-кэш на 4k контекста, и какой потолок токенов в секунду физически возможен на Apple M-series, на десктопном DDR5, на RTX 4090 и на A100. Дальше смотрим на архитектуру Qwen3 как на подопытный экземпляр и на ландшафт сервингов: vLLM, SGLang, llama.cpp, TensorRT-LLM, MLX. К концу модуля у тебя на руках таблица, из которой видно, почему батчинг даёт ×100, а квантизация — ×4, и почему на CPU 8B-модель в fp16 обречена на 5 токенов в секунду.

Один токен изнутри

Forward pass по шагам

Генерация авторегрессионна: на каждом шаге модель получает последовательность длины S и выдаёт распределение над словарём для позиции S+1. Внутри — L одинаковых блоков (у Qwen3-0.6B L=28), в каждом:

  1. RMSNorm входа: x = x / rms(x) * g, где g — обучаемый вектор длины d_{model}.
  2. Self-attention с GQA: проекции q = x W_q, k = x W_k, v = x W_v, затем softmax(qk^T/√d) v, затем o = attn W_o.
  3. Residual: x = x + o.
  4. RMSNorm, затем SwiGLU-MLP: h = silu(x W_gate) * (x W_up), y = h W_down.
  5. Residual: x = x + y.

Сверху — финальный RMSNorm и lm_head (у 0.6B он связан с эмбеддингами, tie_word_embeddings=true).

Ключевое отличие инференса от обучения: на шаге декодинга мы обрабатываем один новый токен, а не всю последовательность. Значит, все матричные умножения вырождаются в матрица-вектор: вместо [B, S, d] × [d, d'] получаем [B, 1, d] × [d, d']. Это меняет всё: GEMM упирается в вычисления, GEMV упирается в память.

Что читается, что считается

Для одного токена:

  • FLOPs ≈ 2N, где N — число параметров (одно умножение + одно сложение на параметр). Для 8B это 16.4 Гфлоп на токен, для 0.6B — 1.19 Гфлоп. Форвард-пас по промпту длины S стоит 2NS флоп.
  • Байты, которые надо прочитать из памяти = размер всех весов (при батче 1 и без учёта KV). Для 8B в fp16 — 16.4 ГБ, для 0.6B — 1.19 ГБ.

Отношение = арифметическая интенсивность: 2N / 2N = 1 флоп/байт для fp16. Точка перегиба (ridge point) у A100: 312 Тфлоп fp16 / 2.04 ТБ/с = 153 флоп/байт. Мы работаем в 150 раз левее пика — GPU при декодинге загружен на ~0.6% по вычислениям и на 100% по памяти. Отсюда главный вывод модуля: декодинг ограничен пропускной способностью памяти, а не FLOPs.

Оговорка про BPE: байтовые патчи

BPE — инженерный компромисс, а не физика. В проде 2026 он по-прежнему доминирует, но главный концептуальный вызов — Byte Latent Transformer (Meta): модель работает с сырым байтовым входом и группирует его в патчи с динамической границей, вместо фиксированного BPE-словаря (Meta BLT). Тезис работы — «Patches scale better than tokens»: патчи масштабируются лучше, чем токены, и у байтового входа нет vocabulary-артефактов (неравномерная «цена» слов в разных языках, как ~2–3 токена на русское слово в Qwen). Для арифметики инференса это меняет единицу счёта: сколько байт на «токен» и как патчи распределяют compute, становится вопросом архитектуры, а не константой словаря. Курс держится за BPE как за рабочий стандарт, но помните: это выбор, который оспаривается.

Арифметика: где живёт время

Потолок декодинга

Время на один токен при батче 1:

t_{token} \approx \frac{\text{байты весов}}{\text{BW}} + \text{накладные расходы ядер}

Потолок токенов в секунду: tok/s = BW / bytes_weights. Для W4-квантованных весов делитель в 4 раза меньше — потолок в 4 раза выше. Это единственная причина, по которой квантизация существует.

Пропускная способность памяти типичных устройств:

УстройствоBW, ГБ/сfp16 TFLOPSB^* = TFLOPS/BW
Apple M1 (8 ядер)68~2.638
Apple M3 Max400~2870
Десктоп DDR5, 2 канала90~1.5 (AVX-512)17
Сервер EPYC, 12 каналов DDR5~460~37
RTX 40901008165164
A100 80GB SXM2039312153
H100 SXM3350989295

B^* — точка перелома батчинга, см. ниже.

Таблица потолков (fp16 и W4)

Считаем честно: 0.6B fp16 = 1.192 ГБ, 8B fp16 = 16.38 ГБ, W4 (4.5 бита на параметр с учётом scales) — 0.335 ГБ и 4.61 ГБ.

Устройство0.6B fp160.6B W48B fp168B W4
M1, 68 ГБ/с57 tok/s2034.215
M3 Max, 400 ГБ/с33611942487
DDR5, 90 ГБ/с762695.520
RTX 4090, 1008 ГБ/с846300762219
A100, 2039 ГБ/с17116082125442
H100, 3350 ГБ/с28109991205727

Это теоретические потолки при идеальных ядрах и батче 1. Реальность: 60–80% от них. На A100 восьмимиллиардная модель в fp16 даёт ~80–100 tok/s на поток — ровно то, что видят люди, запускающие vllm serve Qwen/Qwen3-8B без батчинга.

Заметь: 0.6B на ноутбуке M1 в fp16 упирается в 57 tok/s, а на глаз человек читает ~10 tok/s. То есть маленькие модели на Apple Silicon комфортны, большие — нет: 8B на M1 даёт 4 tok/s, это медленнее чтения.

Четвёртая ось железа: unified memory

До этого момента железо делилось на три класса, и у каждого своя форма боли:

  1. CPU + DRAM — bandwidth в единицы–сотни ГБ/с, зато терабайты памяти и дешёвый гигабайт.
  2. Дискретная GPU — гигантский BW (1–3 ТБ/с), но ёмкость 8–80 ГБ на плате и PCIe между хостом и устройством.
  3. CPU-сервер с большим числом каналов — компромисс: ~460 ГБ/с при сотнях ГБ памяти, но дорогая плата.

Unified memory — четвёртая ось: Apple M-series (и их серверные аналоги) физически распаяли память вокруг одного кристалла, и CPU, и GPU обращаются к одним и тем же адресам. Из этого следует три инженерных следствия, а не маркетинговых:

  • Нет PCIe. В дискретном стеке веса едут по шине при загрузке, KV-кэш и активации — при каждом обмене CPU↔GPU. ~16 ГБ весов 8B-модели по PCIe 4.0 x16 (~32 ГБ/с в каждую сторону) — это полсекунды на одну пересылку. В unified memory это указатель, а не копия: model weights грузятся один раз в mmap-able файл и видны обеим сторонам.
  • Ёмкость = объём ОЗУ машины. Mac Studio с 192–512 ГБ unified memory грузит модели, которые не влезают ни в одну consumer-GPU: 70B в fp16 (140 ГБ) — это локально решаемая задача, тогда как 2×RTX 4090 дают только 48 ГБ и требуют квантования. Потолок декодинга при этом остаётся потолком по BW из таблицы выше: 400 ГБ/с у M3 Max против 1008 ГБ/с у 4090 — модель в 2.5 раза больше памяти доступна, но и читается она в 2.5 раза медленнее.
  • Роскошь ленивой загрузки. Поскольку адресное пространство общее, можно mmap-ить W4-веса с диска и не хранить весь тензор в памяти: страница подгружается при первом touch. В дискретном стеке это требовало бы явного CPU→GPU DMA-конвейера.

Отсюда правило для курса: на unified memory bottleneck — только bandwidth; на дискретной GPU к нему добавляется ёмкость платы и PCIe; на CPU — только bandwidth и её цена. Каждая из трёх «болей» диктует свои оптимизации: на Apple — выжимать BW (квантизация, fused-ядра), на дискретной GPU — упаковывать KV и батч, на CPU — квантовать агрессивно и держать веса mmap-нутыми.

Это не значит, что unified memory «лучше». Это значит, что там другая точка на кривой цена/BW/ёмкость: та же 8B-модель, которая на 4090 летает на 62 tok/s (fp16) с 12 контекстами, на M3 Max даст 24 tok/s, зато на Mac Studio с 512 ГБ можно поднять модель, которая на 4090 не запустится вовсе. Выбор железа — это выбор точки на этой кривой под конкретную задачу, а не «лучше/хуже».

Prefill против decode

У генерации две фазы с разной физикой.

Prefill: обрабатываем промпт длины S за один проход. FLOPs = 2NS, байты весов читаются один раз. Арифметическая интенсивность ≈ S флоп/байт. При S=4096 это 4096 против ridge point 153 — мы в 27 раз правее пика, то есть compute-bound. Время prefill для 8B на 4k токенов: 4096 \times 16.4 Гфлоп = 67 Тфлоп; при 50% MFU на A100 (156 Тфлоп реальных) — 0.43 с. Это и есть time-to-first-token.

Decode: по одному токену, memory-bound, ~125 tok/s на A100 для 8B. 100 токенов ответа — 0.8 с. Итого запрос: 0.43 с ожидания + 0.8 с генерации.

Практическое следствие: длинный промпт бьёт по TTFT, длинный ответ — по inter-token latency, и оптимизируют их разными средствами (chunked prefill, prefix caching против квантизации и спекулятивного декодинга).

Батчинг и точка перелома

При батче B веса читаются один раз на B токенов. Потолок становится B × BW / bytes, пока не упрёмся в вычисления: peak_flops / 2N. Точка перелома:

B^* = \frac{\text{peak\_flops} / 2N}{\text{BW} / 2N} = \frac{\text{peak\_flops}}{\text{BW}}

Для fp16 B^* — свойство устройства, а не модели: 153 для A100, 164 для 4090, 295 для H100, 17 для CPU. До B^* пропускная способность растёт линейно, после — выходит на плато.

Для 8B на A100: одиночный поток 125 tok/s, при B=32 — 4000 tok/s, при B=153 — 19 000 tok/s (потолок по вычислениям). Это ×150 к одиночному потоку. Ровно поэтому PagedAttention и continuous batching — не академические упражнения, а разница между USD 4.5 и USD 0.03 за миллион токенов.

Экономика токена

A100 за USD 2/час = USD 0.00056/с. Одиночный поток 8B fp16, 125 tok/s: USD 4.4 за 1M токенов. Батч 128: 16 000 tok/s → USD 0.035 за 1M. Разница в 128 раз — это и есть весь бизнес инференса. Каждый wasted байт в KV-кэше и каждая фрагментация памяти превращаются в деньги напрямую.

Три пути курса

Курс идёт по трём стекам параллельно, потому что одни и те же идеи выглядят по-разному в зависимости от того, где живёт память.

ПутьСтекЧто строимГде запускается
MLX / Apple Silicon (ядро курса)tiny-llm, mlx, Metalполный движок: forward pass, KV-кэш, paged attention, continuous batching, сэмплинг, спекулятивное декодированиеMac с M-чипом, unified memory
CUDA / PyTorchllm.c, FlashAttention, Tritonkernel-level: layernorm, fused matmul, attention без материализации S \times SNVIDIA GPU
CPU / GGUFllama.cppквантизация k-quants, mmap весов, SIMD-ядра, serverлюбая машина, включая этот ноутбук

Четвёртая ось: класс железа

Три стека выше — это ось программного пути. Она не совпадает с осью железа. Помимо дискретной GPU и обычного CPU+DRAM есть класс устройств с когерентной unified memory: Apple M-series, AMD Ryzen AI Max+ 395 / Strix Halo и NVIDIA DGX Spark на GB10. У DGX Spark официально заявлены до 128 ГБ unified memory, до 1 PFLOP FP4 и ConnectX-сеть для объединения до 4 систем (официальная страница NVIDIA); у Ryzen AI Max+ 395 — до 128 ГБ LPDDR5X-8000 и Radeon 8060S (официальная страница AMD). Это не «ещё один Mac»: объём памяти, пропускная способность и зрелость ROCm/Metal/CUDA меняют оптимальную раскладку модели.

Отдельный класс — гибридная нода: unified-memory бокс держит большую модель или prefill, а дискретная NVIDIA GPU выполняет decode, draft-модель или быструю голову. Между ними передаются не веса на каждом шаге, а KV-кэш или компактный промежуточный результат; порог выгодности разбирается в модуле 13, рецепты placement и MoE-offload — в модуле 17. Поэтому «три пути» не исчерпывают ландшафт: это три стека, поверх которых можно собрать несколько разных аппаратных топологий.

MLX выбран ядром, потому что unified memory убирает шину PCIe из уравнения: и CPU, и GPU видят одни и те же байты, и можно честно измерять, где кончается bandwidth. CUDA-путь даёт доступ к настоящим ядрам и roofline. CPU-путь показывает, что происходит, когда bandwidth в 20 раз меньше, и почему квантизация там не роскошь, а необходимость.

Подопытная модель: Qwen3

Qwen3 — современное семейство с GQA, RMSNorm, SwiGLU, RoPE и QK-norm (RMSNorm на головах q и k, добавка 2 \cdot d_{head} параметров на слой). Dense-варианты: 0.6B, 1.7B, 4B, 8B, 14B, 32B.

Qwen3 остаётся сквозным примером курса, но не единственным стендом: в феврале–марте 2026 вышло семейство Qwen3.5 — MoE 397B-A17B плюс dense-варианты 0.8B–9B с контекстом ~256K, по-прежнему под лицензией Apache 2.0 (официальная линейка Qwen). Верхний dense-сегмент закрыл Qwen3.8-27B (официальный релиз Qwen). Методически ценно, что линейка покрывает оба класса — MoE и dense — на одной лицензии: второй стенд добавляется, не ломая нумерацию ноутбуков. Вся арифметика этого модуля (2N флоп на токен, декодинг по пропускной способности, бюджет KV) переносится на Qwen3.5 дословно — меняются только константы конфига.

ПараметрQwen3-0.6BQwen3-8B
num_hidden_layers2836
hidden_size (d_{model})10244096
num_attention_heads1632
num_key_value_heads88
head_dim128128
intermediate_size307212288
vocab_size151 936151 936
tie_word_embeddingstruefalse
Параметров (расчёт)596 042 7528 190 726 144
Веса fp161.19 ГБ16.38 ГБ
Веса W4 (4.5 бита)0.335 ГБ4.61 ГБ
KV на токен (fp16)112 КиБ144 КиБ
KV на 4k токенов448 МиБ576 МиБ

Обрати внимание на head_dim = 128 при d_{model}=1024: у 0.6B 16 голов по 128 = 2048 на q-проекцию, то есть q-проекция расширяет, а не сжимает. Это сделано, чтобы головы были «нормального» размера на всех масштабах.

GQA — главный источник экономии в KV-кэше. У 8B 32 головы запросов и 8 голов ключей/значений: каждая KV-голова обслуживает 4 q-головы. Без GQA (32 KV-головы) кэш на 4k был бы 2.25 ГиБ вместо 576 МиБ.

Формула KV-кэша, которую надо помнить наизусть:

\text{KV bytes} = 2 \cdot L \cdot n_{kv} \cdot d_{head} \cdot S \cdot B \cdot \text{bytes\_per\_element}

Множитель 2 — ключи и значения. Проверка для 8B: 2 \cdot 36 \cdot 8 \cdot 128 \cdot 4096 \cdot 1 \cdot 2 = 603\,979\,776 байт = 576 МиБ. Сходится.

Полный бюджет памяти при сервинге: веса + KV × batch × ctx + активации + фрагментация. Для 8B на 4090 (24 ГБ): 16.38 ГБ весов оставляют 7.6 ГБ, то есть 12 одновременных последовательностей по 4k. На A100 80 ГБ — 16.38 + KV, помещается ~100 последовательностей по 4k, и именно там начинает работать батчинг.

Ландшафт сервинга

ДвижокКлючевая идеяКогда братьОграничения
vLLMPagedAttention: KV-кэш блоками по 16 токенов, как страницы ОС; continuous batching; prefix cachingGPU-сервинг общего назначения, OpenAI-совместимый APICUDA-first; на CPU/Apple работает медленно
SGLangRadixAttention: префиксы как дерево, переиспользование между запросами; frontend-язык для структурированной генерациимного общих системных промптов, агентные пайплайнысложнее в дебаге
llama.cpp / llama-serverGGUF, k-quants (Q4_K_M и т.п.), mmap, SIMD на CPU, Metal/CUDA бэкендыedge, ноутбуки, CPU-серверы, маленькие моделипропускная способность ниже, чем у GPU-стеков
TensorRT-LLMкомпиляция в engine под конкретную GPU, FP8/INT4, fused kernels, in-flight batchingмаксимум tok/s на NVIDIA, продакшн с фиксированной конфигурациейдолгая сборка, привязка к архитектуре GPU
MLX / mlx-lmunified memory, ленивые графы, квантование в 4 битаApple Silicon, локальная разработканет продакшн-сервинга уровня vLLM

Общая структура у всех одна: планировщик батчей → менеджер KV-кэша → ядра attention/matmul → сэмплер. Различаются тем, насколько эффективно они упаковывают KV-кэш и насколько часто пересобирают батч. В этом курсе мы соберём этот конвейер сами, начиная с наивного (пересчёт всего префикса каждый шаг) и заканчивая paged-KV с continuous batching.

Что ты построишь за курс

К концу курса у тебя будет собственный мини-движок инференса: загрузка safetensors, токенизатор, forward pass на numpy/torch (CPU) и на MLX (Apple Silicon), KV-кэш, paged attention с блоками по 16 токенов, continuous batching с планировщиком, сэмплинг (top-k/top-p/temperature), квантизация весов в int8/int4, спекулятивное декодирование и HTTP-сервер с OpenAI-совместимым /v1/completions. Он будет выдавать те же логиты, что и transformers, и мы это проверим численно.

Задание 00.1: бюджет памяти и потолок декодинга

Реализуй модуль budget.py со следующим интерфейсом:

def params_breakdown(m: ModelSpec) -> dict[str, int]:
    """Ключи: 'attention', 'mlp', 'norms', 'embedding', 'total'.
       Считается по формулам, без загрузки весов."""

def kv_bytes(m: ModelSpec, ctx: int, batch: int = 1, dtype_bytes: int = 2) -> int:
    """Полный размер KV-кэша в байтах."""

def weights_bytes(m: ModelSpec, bits_per_param: float) -> float:
    """bits_per_param: 16.0 для fp16, 8.0 для int8, 4.5 для W4."""

def decode_ceiling(m: ModelSpec, bits_per_param: float, bw_bytes_per_s: float,
                   efficiency: float = 1.0) -> float:
    """Теоретический потолок tok/s при батче 1."""

def batch_breakeven(m: ModelSpec, bits_per_param: float, bw: float, peak_flops: float) -> float:
    """Батч, после которого упираемся в вычисления, а не в память."""

На вход подаются два ModelSpec — Qwen3-0.6B и Qwen3-8B — и список устройств с BW и пиковыми FLOPs. На выходе — таблица из раздела «Арифметика», плюс отдельная строка «итого памяти» для 8B при batch=32, ctx=4096 на каждом устройстве.

Ожидаемый результат: params_breakdown(QWEN3_8B)['total'] = 8 190 726 144, kv_bytes(QWEN3_8B, 4096) = 603 979 776, decode_ceiling(QWEN3_8B, 16.0, 2039e9) ≈ 124.5. Все три числа должны совпасть с точностью до целого.

Задание 00.2: инспекция реального чекпоинта

Реализуй inspect_safetensors.py, который читает только заголовок safetensors-файла (8 байт длины + JSON), не поднимая в память ни одного тензора, и печатает:

  1. общее число тензоров и суммарный размер в байтах;
  2. гистограмму dtype (сколько тензоров и сколько байт на каждый dtype);
  3. агрегацию по префиксам: model.layers.*.self_attn.q_proj и т.п. — сколько байт суммарно;
  4. top-10 самых больших тензоров;
  5. есть ли lm_head.weight (проверка tie_word_embeddings).

Интерфейс:

def read_header(path: Path) -> dict:
    """Возвращает распарсенный JSON-заголовок safetensors."""

def inspect(model_dir: Path) -> dict:
    """Ключи: 'n_tensors', 'total_bytes', 'dtypes', 'by_prefix', 'largest', 'has_lm_head'."""

На вход — директория с model.safetensors (или шардами model-00001-of-00002.safetensors + model.safetensors.index.json). Ожидаемо для Qwen3-0.6B: ≈310 тензоров, ≈1.19 ГБ, has_lm_head == False, dtype только BF16. Для Qwen3-8B: ≈399 тензоров, ≈16.4 ГБ, has_lm_head == True.

Код

budget.py

#!/usr/bin/env python3
"""budget.py — арифметика памяти и потолок декодинга для LLM.

Запуск: python budget.py
Зависимостей нет, только стандартная библиотека.
"""
from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True)
class ModelSpec:
    """Всё, что нужно знать о dense-модели, чтобы посчитать бюджет."""
    name: str
    n_layers: int
    d_model: int
    n_heads: int
    n_kv_heads: int
    head_dim: int
    d_ff: int
    vocab: int
    tied: bool  # tie_word_embeddings


# Значения взяты из config.json Qwen3-0.6B и Qwen3-8B
QWEN3_06B = ModelSpec("Qwen3-0.6B", 28, 1024, 16, 8, 128, 3072, 151936, True)
QWEN3_8B = ModelSpec("Qwen3-8B", 36, 4096, 32, 8, 128, 12288, 151936, False)

# (имя, BW байт/с, пиковые fp16 FLOP/s)
DEVICES = [
    ("Apple M1 (8 ядер)", 68e9, 2.6e12),
    ("Apple M3 Max", 400e9, 28e12),
    ("DDR5 desktop 2ch", 90e9, 1.5e12),
    ("RTX 4090", 1008e9, 165e12),
    ("A100 80GB SXM", 2039e9, 312e12),
    ("H100 SXM", 3350e9, 989e12),
]


def params_breakdown(m: ModelSpec) -> dict[str, int]:
    """Число параметров по группам. QK-norm (2*head_dim на слой) включён в norms."""
    q = m.d_model * m.n_heads * m.head_dim
    k = m.d_model * m.n_kv_heads * m.head_dim
    v = k
    o = m.n_heads * m.head_dim * m.d_model
    gate = m.d_model * m.d_ff
    up = gate
    down = m.d_ff * m.d_model
    norms = 2 * m.d_model + 2 * m.head_dim  # 2 RMSNorm + QK-norm

    per_layer = q + k + v + o + gate + up + down + norms
    emb = m.vocab * m.d_model
    head = 0 if m.tied else m.vocab * m.d_model

    return {
        "attention": (q + k + v + o) * m.n_layers,
        "mlp": (gate + up + down) * m.n_layers,
        "norms": norms * m.n_layers + m.d_model,
        "embedding": emb + head,
        "total": per_layer * m.n_layers + emb + head + m.d_model,
    }


def weights_bytes(m: ModelSpec, bits_per_param: float) -> float:
    """Размер весов в байтах. 16.0 -> fp16, 8.0 -> int8, 4.5 -> W4 со scales."""
    return params_breakdown(m)["total"] * bits_per_param / 8.0


def kv_bytes(m: ModelSpec, ctx: int, batch: int = 1, dtype_bytes: int = 2) -> int:
    """KV-кэш: 2 (K и V) * слои * kv-головы * head_dim * токены * батч * байты."""
    per_token = 2 * m.n_layers * m.n_kv_heads * m.head_dim * dtype_bytes
    return per_token * ctx * batch


def decode_ceiling(m: ModelSpec, bits_per_param: float, bw_bytes_per_s: float,
                   efficiency: float = 1.0) -> float:
    """tok/s при батче 1: сколько раз вес модели влезает в секунду пропускной способности."""
    return bw_bytes_per_s / weights_bytes(m, bits_per_param) * efficiency


def batch_breakeven(m: ModelSpec, bits_per_param: float, bw: float,
                    peak_flops: float) -> float:
    """Батч, на котором декодинг перестаёт быть memory-bound.

    B* = (peak_flops / 2N) / (BW / bytes_weights)
    Для fp16 упрощается до peak_flops / BW.
    """
    compute_bound = peak_flops / (2 * params_breakdown(m)["total"])
    memory_bound = bw / weights_bytes(m, bits_per_param)
    return compute_bound / memory_bound


def human(n: float) -> str:
    for unit in ("Б", "КиБ", "МиБ", "ГиБ", "ТиБ"):
        if abs(n) < 1024 or unit == "ТиБ":
            return f"{n:.2f} {unit}"
        n /= 1024


def report(m: ModelSpec, ctx: int = 4096, batch: int = 32) -> None:
    p = params_breakdown(m)
    print(f"\n=== {m.name} ===")
    print(f"параметров всего      : {p['total']:,}")
    print(f"  attention           : {p['attention']:,}")
    print(f"  mlp                 : {p['mlp']:,}")
    print(f"  norms               : {p['norms']:,}")
    print(f"  embedding (+lm_head): {p['embedding']:,}")
    for bits, tag in ((16.0, "fp16"), (8.0, "int8"), (4.5, "W4")):
        print(f"веса {tag:>5}          : {human(weights_bytes(m, bits))}")
    print(f"KV на токен (fp16)    : {human(kv_bytes(m, 1))}")
    print(f"KV на {ctx} токенов      : {human(kv_bytes(m, ctx))}")
    print(f"KV на {ctx}x{batch}        : {human(kv_bytes(m, ctx * batch))}")

Как проверить

  1. Запустите скрипт для одной модели при ctx = 2k, 8k, 32k и batch = 1, 2, 4. Убедитесь, что KV на ctx растёт линейно, а KV на ctx x batch — ровно пропорционально batch.
  2. Сверьте параметров всего с суммой по конфигу и с известным размером модели. Если расходится, проверьте tie_word_embeddings, отдельный lm_head и учёт norms.
  3. Проверьте формулу KV вручную: 2 * n_layers * n_kv_heads * head_dim * dtype_bytes. Для GQA n_kv_heads должен быть меньше n_heads; если в расчёте стоит n_heads, KV завышен.
  4. Сравните оценку с фактическим пиком памяти: прогрейте модель, сгенерируйте ctx токенов и снимите torch.cuda.max_memory_allocated(). Разница в 10–20% нормальна за счёт активаций, фрагментации и CUDA-контекста.
  5. Для int8/W4 проверьте, что в weights_bytes учитываются только веса, а KV и активации считаются отдельно. Квантованный KV включайте в расчёт только если он реально включён.
  6. Добавьте unit-тест на синтетической модели: kv_bytes(m, 2*ctx) == 2*kv_bytes(m, ctx) и kv_bytes(m, ctx*2) == 2*kv_bytes(m, ctx).

Сводка

  • Память инференса складывается из весов, KV-кэша и служебных буферов; на длинном контексте KV-кэш часто становится главным ограничением.
  • Размер весов считается как params * bits / 8; для fp16 это 2 байта на параметр, для int8 — 1 байт, для W4 — около 0.5 байта плюс накладные расходы на масштабы.
  • KV на один токен: 2 * n_layers * n_kv_heads * head_dim * dtype_bytes; множитель 2 — это ключи и значения.
  • GQA и MQA уменьшают число KV-голов, поэтому кэш падает в n_heads / n_kv_heads раз относительно обычного MHA.
  • Батч увеличивает KV линейно: KV(ctx, batch) = batch * KV(ctx, 1), поэтому concurrent-запросы надо планировать вместе с длиной контекста.
  • Квантование весов не уменьшает KV автоматически; для экономии нужен отдельный quantized KV cache и проверка качества.


Навигация по курсу: Хаб курса · Следующий: От matmul к тексту: тензоры и базовые операторы